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

next-device-routes

v0.4.0

Published

Device-type and crawler aware routing for the Next.js App Router. No middleware, no dependencies.

Readme

next-device-routes

Device-type and crawler aware routing for the Next.js App Router.

No middleware, no runtime cost, no dependencies. Your folder structure is scanned once when next.config loads and compiled into native rewrites, which Next.js matches against the User-Agent header at the edge.

npm install next-device-routes

Quick start

// next.config.ts
import type { NextConfig } from 'next'
import { withDeviceRoutes } from 'next-device-routes'

const config: NextConfig = withDeviceRoutes({
  reactStrictMode: true,
})

export default config

JavaScript works the same way:

// next.config.mjs
import { withDeviceRoutes } from 'next-device-routes'

export default withDeviceRoutes({ reactStrictMode: true })

Then add a variant folder inside app/:

app/
├─ layout.tsx              shared root layout
├─ page.tsx                base — desktop and anything unmatched
├─ about/page.tsx
├─ contact/page.tsx
├─ blog/[slug]/page.tsx
│
├─ mobile/                 phones
│  ├─ layout.tsx           optional, wraps only this variant
│  ├─ page.tsx             →  /
│  ├─ about/page.tsx       →  /about
│  └─ blog/[slug]/page.tsx →  /blog/:slug
│                          /contact is absent → phones fall back to the base page
├─ tablet/
│  └─ page.tsx
└─ bot/                    crawlers and link previews
   └─ about/page.tsx

The URL never changes. /about stays /about for every visitor; only the rendered page differs. A route that does not exist inside a variant folder is simply not rewritten, so the base route serves it.

| Request | User-Agent | Rendered page | | --- | --- | --- | | / | Chrome on Windows | app/page.tsx | | / | iPhone | app/mobile/page.tsx | | / | iPad | app/tablet/page.tsx | | /about | Android | app/mobile/about/page.tsx | | /about | Googlebot | app/bot/about/page.tsx | | /contact | iPhone | app/contact/page.tsx (fallback) |

A fallback page like /contact above has no way to tell devices apart on its own — see Reading the device in your components for a hook and context that cover exactly that case without making the rest of the site dynamic.

Supported route types

| Folder | Generated source | | --- | --- | | about | /about | | [slug] | /:slug | | [...path] | /:path+ | | [[...path]] | /:path* | | (group) | no segment, contents are hoisted |

_private, @parallel and (.)intercepting folders are skipped.

TypeScript

Types ship with the package — there is no @types/next-device-routes to install.

  • next.config.ts is supported, and the return value is a plain NextConfig.
  • page.tsx and page.ts are detected in variant folders, alongside .jsx, .js, .mdx and .md.
  • Options are checked, so typos like varients or { match: 'Mobi' } fail at compile time.
  • A function-shaped config keeps its shape and is typed with Next's own (phase, { defaultConfig }) signature.
import type { NextConfig } from 'next'
import { withDeviceRoutes, defaultVariants } from 'next-device-routes'
import type { DeviceRoutesOptions, Variant, NextConfigFn } from 'next-device-routes'

const options: DeviceRoutesOptions = {
  variants: { ...defaultVariants, tv: ['Tizen'] satisfies string[] },
  redirectVariantPaths: true,
}

const config: NextConfig = withDeviceRoutes({ reactStrictMode: true }, options)
export default config

If your project uses Next's pageExtensions, it is picked up automatically — including the page.tsx naming convention:

withDeviceRoutes({ pageExtensions: ['page.tsx', 'page.ts'] })  // finds app/mobile/about/page.page.tsx

Note: Next.js 15 cannot load next.config.ts under TypeScript 7 — this is a Next.js limitation, unrelated to this package. Use TypeScript 5.x, or a next.config.mjs.

Options

withDeviceRoutes(nextConfig, {
  variants: {
    // key = folder name inside app/ — ORDER IS PRIORITY
    bot:    { match: ['Googlebot', 'bingbot'] },
    tablet: { match: ['iPad', 'Tablet'] },
    mobile: { match: ['Mobi', 'iPhone'], exclude: ['iPad'] },
  },
  appDir: 'src/app',
  redirectVariantPaths: true,
  permanentRedirects: false,
  debug: true,
})

| Option | Default | Description | | --- | --- | --- | | variants | bot, tablet, mobile | Folder name → User-Agent rule. Key order sets priority. | | appDir | app, then src/app | App Router directory, relative to the project root. | | pageExtensions | nextConfig.pageExtensions, else tsx, ts, jsx, js, mdx, md | Page file extensions to scan for. | | redirectVariantPaths | false | Redirect /mobile/:path* → /:path* so variant URLs are not indexed. | | permanentRedirects | false | Use 308 instead of 307 for those redirects. | | cwd | process.cwd() | Project root used to resolve appDir. | | debug | false | Print every generated rewrite during build. |

Variant rules

match and exclude are plain substrings, compared case-insensitively against the whole User-Agent header. A variant only applies when match hits and exclude does not.

mobile: ['Mobi', 'iPhone']                      // shorthand for { match: [...] }
mobile: { match: ['Mobi'], exclude: ['iPad'] }  // full form
tablet: false                                   // disabled

variants replaces the defaults entirely. To extend them instead:

import { withDeviceRoutes, defaultVariants } from 'next-device-routes'

export default withDeviceRoutes(config, {
  variants: { ...defaultVariants, tv: ['SmartTV', 'Tizen', 'WebOS'] },
})

Default variants, in priority order:

  • bot — Google, Bing, Yandex, Baidu, DuckDuckGo, Apple; social previews (Facebook, X, LinkedIn, Slack, Telegram, WhatsApp, Discord); AI crawlers (GPTBot, ClaudeBot, PerplexityBot).
  • tablet — iPad, Kindle, Silk, PlayBook, and UAs containing Tablet.
  • mobile — iPhone, iPod, Android, Mobi, Windows Phone, BlackBerry, Opera Mini, webOS, with tablets excluded.

Working with your own config

Other keys pass through untouched, your rewrites and redirects are preserved, and a function-shaped config stays a function:

export default withDeviceRoutes(async (phase, { defaultConfig }) => ({
  reactStrictMode: true,
  async rewrites() {
    return [{ source: '/old', destination: '/about' }]
  },
}))

Reading the device in your components

useDevice() reads the device from the closest <DeviceProvider>. There are two ways to feed it, and the difference decides whether a route can be statically generated.

Static path — the device is already known. The rewrites pick a variant before rendering starts, so inside app/mobile/ the device is a build-time fact. Give the provider a literal; no request API is touched and every route stays static:

// app/layout.tsx — shared by every route, so it must not read the request
import { DeviceProvider } from 'next-device-routes/context'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <DeviceProvider variant={null}>{children}</DeviceProvider>
      </body>
    </html>
  )
}
// app/mobile/layout.tsx — the closest provider wins
import { DeviceProvider } from 'next-device-routes/context'

export default function MobileLayout({ children }: { children: React.ReactNode }) {
  return <DeviceProvider variant="mobile">{children}</DeviceProvider>
}

Dynamic path — only the request can tell. A fallback route with no variant folder — like /contact above — is served to every device. To tell them apart there, render <DeviceBoundary> in that segment only. It calls getDevice() and provides the result:

// app/contact/layout.tsx
import { DeviceBoundary } from 'next-device-routes/server'

export default function ContactLayout({ children }: { children: React.ReactNode }) {
  return <DeviceBoundary>{children}</DeviceBoundary> // only /contact becomes dynamic
}
// app/components/nav.tsx — a Client Component, under either kind of provider
'use client'
import { useDevice } from 'next-device-routes/context'

export function Nav() {
  const { isMobile } = useDevice()
  return isMobile ? <MobileNav /> : <DesktopNav />
}

<DeviceBoundary variants={variants}> accepts the same variants as getDevice(). The previous form — await getDevice() in a layout, then <DeviceProvider variant={device.variant}> — still works and behaves identically.

getDevice() runs the same matching rules as withDeviceRoutes and returns:

{ variant: 'mobile' | 'tablet' | 'bot' | string | null, isMobile, isTablet, isBot, isBase }

variant is null and isBase is true when nothing matched (the common desktop case). isMobile / isTablet / isBot are convenience flags for the three default variant names; for a custom variant (like a tv folder), compare variant === 'tv' directly.

You don't need the client hook at all if a Server Component is enough — getDevice() works anywhere next/headers does (Server Components, Route Handlers, Server Actions, generateMetadata):

export default async function Page() {
  const device = await getDevice()
  return device.isBot ? <StaticVersion /> : <InteractiveVersion />
}

Keep it in sync with your variants. If you passed a custom variants object to withDeviceRoutes, pass the same object to getDevice({ variants }) — otherwise the two can disagree about what counts as "mobile". Defining it once in its own module and importing it in both places avoids the drift entirely:

// device-variants.ts
export const variants = { bot: [...], tablet: [...], mobile: [...] }

Static vs. dynamic rendering

getDevice() and <DeviceBoundary> read headers(). During next build, Next.js prerenders each route by rendering its whole tree — root layout, nested layouts, page. If any of them reads headers(), that route bails out of prerendering and is rendered on every request. A root layout belongs to every route, so reading the device there makes the entire site dynamic.

The rule: request reads go only in the segments that need them; shared layouts get a literal. Then a route is dynamic exactly when its own tree contains getDevice() / <DeviceBoundary>. The example does this — only /contact is dynamic; every other route, including the mobile and tablet variants, stays static.

Limits imposed by Next.js itself:

  • Granularity is the route. Without Partial Prerendering, one request read anywhere in a route's tree makes that whole route dynamic. There is no supported way to read a header and still prerender the same route — the value does not exist at build time. Forcing it (dynamic = 'force-static') makes headers() return empty, so the device is always the base.
  • With Cache Components (PPR), wrap <DeviceBoundary> in <Suspense>: the route keeps a prerendered static shell and only the boundary's subtree streams at request time.
  • Client-side detection (reading navigator.userAgent in an effect) keeps a route static but renders the base markup first, then changes after hydration. Prefer a variant folder.
  • Request isolation comes from Next.js: headers() is scoped to the current request with AsyncLocalStorage. This package keeps no request data in module state — only the compiled variants rules are cached, per variants object.

Caveats

CDN caching. One URL now returns different HTML per device, so any cache in front of your app must key on the User-Agent (or a normalized device header your CDN provides). Without that, the first variant cached is served to everyone. Next.js overwrites the Vary header itself, so this cannot be set from next.config — configure it on the CDN.

Dev server. Routes are scanned when next.config loads. Restart next dev after adding a page to a variant folder.

iPadOS. Modern iPad Safari sends a desktop macOS User-Agent by default and is indistinguishable from a Mac at the header level. Android tablets rarely include Tablet in their UA and fall through to mobile; add their tokens to the tablet variant if you need them.

Cloaking. Serving crawlers a page whose content differs materially from what users see violates search engine guidelines. The bot variant is meant for rendering strategy — static, JS-free markup — not for different content.

Shared root layout. app/layout.tsx wraps every variant. Put variant-only chrome in app/mobile/layout.tsx, and keep getDevice() / <DeviceBoundary> out of the root layout — see Static vs. dynamic rendering.

How it works

Static routes are emitted into rewrites().beforeFiles so they take priority over the identically named base page. Dynamic routes go into afterFiles, so real files in public/ and /_next/* assets are never swallowed by a catch-all. Within a variant, routes are sorted by specificity — static segments first, catch-alls last — mirroring Next's own precedence.

User-Agent conditions become has / missing entries on each rewrite. Next compiles these into a case-sensitive anchored RegExp, so each token is expanded into character classes (iPad → [iI][pP][aA][dD]) to make matching case-insensitive.

getDevice() doesn't reuse those rewrite conditions — it compiles the same variants into a plain case-insensitive RegExp and tests the request's user-agent header directly. Simpler, since a runtime .test() call has no need for Next's anchored, flag-less rewrite format.

Example

A runnable TypeScript app lives in example/.

cd example && npm install && npm run build && npm start

curl -s localhost:3000/ -A "Mozilla/5.0 (Windows NT 10.0) Chrome/120"
curl -s localhost:3000/ -A "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Mobile"

Publishing your own fork

npm view <your-package-name>   # E404 means the name is free
npm login
npm test
npm pack --dry-run             # inspect the tarball contents
npm publish

Update name, author, repository and LICENSE first. files in package.json already limits the tarball to index.js, server.js, context.js, their .d.ts files, lib/, README.md and LICENSE — no .npmignore needed. Bump releases with npm version patch|minor|major; a published version can never be overwritten.

Development

npm install               # pulls in next/react/react-dom as devDependencies, for the test suite only
npm test                  # node:test unit tests, no test framework dependency
npm run test:integration  # real `next build` of test/fixtures/rendering-app

The integration suite checks which routes Next.js prerenders, that request values reach the components that read them, and that concurrent requests never see each other's headers.

The package itself has zero runtime dependencies; next and react are peerDependencies (any Next.js app already has both) and only appear as devDependencies here so npm test can exercise next/headers and render <DeviceProvider> with real React.

License

MIT