next-device-routes
v0.4.0
Published
Device-type and crawler aware routing for the Next.js App Router. No middleware, no dependencies.
Maintainers
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-routesQuick start
// next.config.ts
import type { NextConfig } from 'next'
import { withDeviceRoutes } from 'next-device-routes'
const config: NextConfig = withDeviceRoutes({
reactStrictMode: true,
})
export default configJavaScript 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.tsxThe 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.tsis supported, and the return value is a plainNextConfig.page.tsxandpage.tsare detected in variant folders, alongside.jsx,.js,.mdxand.md.- Options are checked, so typos like
varientsor{ 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 configIf 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.tsxNote: Next.js 15 cannot load
next.config.tsunder TypeScript 7 — this is a Next.js limitation, unrelated to this package. Use TypeScript 5.x, or anext.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 // disabledvariants 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 containingTablet.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') makesheaders()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.userAgentin 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 compiledvariantsrules are cached, pervariantsobject.
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 publishUpdate 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-appThe 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
