Next.js Guide

How to set up Tamagui with Next.js

Check out the source for this site  to see a good example of a full featured Next.js website as well, especially the next.config.js and tamagui.config.ts.

Install

Running npm create tamagui@latest let's you choose the starter-free starter which is a very nicely configured Next.js app where you can take or leave whatever you want.

Create a new Next.js  project

npx create-next-app@latest

To configure Tamagui for Next.js, add the @tamagui/next-plugin and set it up in your next config. We'll show how to configure it for both pages and app router in this guide. See the compiler install docs for more options.

Add @tamagui/next-plugin to your project:

yarn @tamagui/next-plugin

Choose Next.js routing option:

Pages router

next.config.js

Add the plugin to your next.config.js:

next.config.js

const { withTamagui } = require('@tamagui/next-plugin')
module.exports = function (name, { defaultConfig }) {
let config = {
...defaultConfig,
// ...your configuration
}
const tamaguiPlugin = withTamagui({
config: './tamagui.config.ts',
components: ['tamagui'],
})
return {
...config,
...tamaguiPlugin(config),
}
}

pages/_document.tsx

You'll want to set up a _document.tsx and gather both the react-native-web style object using AppRegistry, as well as Tamagui styles using tamaguiConfig.getCSS() into the head element.

We are only importing react-native here because if you use tamagui views like Input or Image they rely on React Native Image/Input (and therefore on the web, react-native-web). If you are just using core, or aren't using Image or Input in tamagui, you can forego the entire AppRegistry import and setup here. We're working on making even tamagui completely de-coupled from RN/RNW by building their own image and input components eventually.

Configure _document.tsx:

_document.tsx

import NextDocument, {
DocumentContext,
Head,
Html,
Main,
NextScript,
} from 'next/document'
import { StyleSheet } from 'react-native'
import { config } from '@tamagui/config/v3'
import { createTamagui } from 'tamagui'
const tamaguiConfig = createTamagui(config)
// you usually export this from a tamagui.config.ts file:
// import tamaguiConfig from '../tamagui.config'
export default class Document extends NextDocument {
static async getInitialProps({ renderPage }: DocumentContext) {
const page = await renderPage()
// @ts-ignore RN doesn't have this type
const rnwStyle = StyleSheet.getSheet()
return {
...page,
styles: (
<>
<style id={rnwStyle.id} dangerouslySetInnerHTML={{ __html: rnwStyle.textContent }} />
<style dangerouslySetInnerHTML={{ __html: tamaguiConfig.getCSS(), }} />
</>
),
}
}
render() {
return (
<Html lang="en">
<Head>
<meta id="theme-color" name="theme-color" />
<meta name="color-scheme" content="light dark" />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}

pages/_app.tsx

Add TamaguiProvider:

tamagui.config.ts

// Optional: add the reset to get more consistent styles across browsers
import '@tamagui/core/reset.css'
import { NextThemeProvider, useRootTheme } from '@tamagui/next-theme'
import { AppProps } from 'next/app'
import Head from 'next/head'
import React, { useMemo } from 'react'
import { TamaguiProvider, createTamagui } from 'tamagui'
import { config } from '@tamagui/config/v3'
const tamaguiConfig = createTamagui(config)
// you usually export this from a tamagui.config.ts file:
// import tamaguiConfig from '../tamagui.config'
// make TypeScript type everything based on your config
type Conf = typeof tamaguiConfig
declare module '@tamagui/core' {
interface TamaguiCustomConfig extends Conf {}
}
export default function App({ Component, pageProps }: AppProps) {
// memo to avoid re-render on dark/light change
const contents = useMemo(() => {
return <Component {...pageProps} />
}, [pageProps])
return (
<NextThemeProvider>
<Head>
<script dangerouslySetInnerHTML={{ // avoid flash of animated things on enter: __html: `document.documentElement.classList.add('t_unmounted')`, }} />
</Head>
<TamaguiProvider config={tamaguiConfig} disableInjectCSS disableRootThemeClass>
{contents}
</TamaguiProvider>
</NextThemeProvider>
)
}

Because custom getCSS() is used in _document.tsx, disableInjectCSS is added to TamaguiProvider

Themes

We've created a package that works with Tamagui to properly support SSR light/dark themes that also respect user system preference, called @tamagui/next-theme. It assumes your light/dark themes are named as such, but you can override it. This is pre-configured in the create-tamagui starter.

yarn @tamagui/next-theme

Here's how you'd set up your _app.tsx:

_app.tsx

// Optional: add the reset to get more consistent styles across browsers
import '@tamagui/core/reset.css'
import { NextThemeProvider, useRootTheme } from '@tamagui/next-theme'
import { AppProps } from 'next/app'
import Head from 'next/head'
import React, { useMemo } from 'react'
import { TamaguiProvider, createTamagui } from 'tamagui'
import { config } from '@tamagui/config/v3'
const tamaguiConfig = createTamagui(config)
// you usually export this from a tamagui.config.ts file:
// import tamaguiConfig from '../tamagui.config'
// make TypeScript type everything based on your config
type Conf = typeof tamaguiConfig
declare module '@tamagui/core' {
interface TamaguiCustomConfig extends Conf {}
}
export default function App({ Component, pageProps }: AppProps) {
const [theme, setTheme] = useRootTheme()
// memo to avoid re-render on dark/light change
const contents = useMemo(() => {
return <Component {...pageProps} />
}, [pageProps])
return (
<NextThemeProvider onChangeTheme={setTheme as any}>
<Head>
<script dangerouslySetInnerHTML={{ // avoid flash of animated things on enter: __html: `document.documentElement.classList.add('t_unmounted')`, }} />
</Head>
<TamaguiProvider config={tamaguiConfig} disableInjectCSS disableRootThemeClass defaultTheme={theme} >
{contents}
</TamaguiProvider>
</NextThemeProvider>
)
}

We recommend memo-ing children so they don't re-render.

If you need to access the current theme, say for a toggle button, you will then use the useThemeSetting hook. We'll release an update in the future that makes this automatically work better with Tamagui's built-in useThemeSetting.

index.tsx

import { useState } from 'react'
import { Button, useIsomorphicLayoutEffect } from 'tamagui'
import { useThemeSetting } from '@tamagui/next-theme'
export default function Home() {
const themeSetting = useThemeSetting()
const [clientTheme, setClientTheme] = useState<string>('dark')
useIsomorphicLayoutEffect(() => {
setClientTheme(themeSetting.current || 'dark')
}, [themeSetting.current, themeSetting.resolvedTheme])
return <Button onPress={themeSetting.toggle}>Change theme: {clientTheme}</Button>
}

Mount animations

Animations that run through JS drivers and have enterStyle set will want to add the following script that allows for hiding those animations before the JS runs. This is the "right way" to handle things, as it allows for disabling JS entirely and not accidentally hiding all unmounted things. Meanwhile, it still properly avoids a flash of mounted style for SSR pages.

Add this to your _app.tsx render output:

_app.tsx

<Head>
<script dangerouslySetInnerHTML={{ // avoid flash of entered elements before enter animations run: __html: `document.documentElement.classList.add('t_unmounted')`, }} />
</Head>

Performance

Use outputCSS for better performance by shaking out all the js and generating only CSS styles at build-time.

Add outputCSS to your next.config.js:

next.config.js

const tamaguiPlugin = withTamagui({
config: './tamagui.config.ts',
components: ['tamagui'],
outputCSS: process.env.NODE_ENV === 'production' ? './public/tamagui.css' : null,
})

We recommend using this only for production so you get hot reloading and easier console logging during dev mode.

And then add this to include the CSS file generated at build-time:

_app.tsx

if (process.env.NODE_ENV === 'production') {
require('../public/tamagui.css')
}

Add exclude option to getCSS() in _document.tsx:

_document.tsx

<head>
<style dangerouslySetInnerHTML={{ __html: tamaguiConfig.getCSS({ exclude: process.env.NODE_ENV === 'production' ? 'design-system' : null, }), }} />
</head>

App router

Tamagui includes Server Components support for the Next.js app directory with use client  support. We're also actively working on a new mode that enables full server-side support with some limitations.

next.config.js

The Tamagui plugin is optional but helps with compatibility with the rest of the React Native ecosystem. It requires CommonJS for now as the optimizing compiler makes use of a variety of resolving features that haven't been ported to ESM yet. Be sure to rename your next.config.mjs to next.config.js before adding it:

next.config.js

const { withTamagui } = require('@tamagui/next-plugin')
module.exports = function (name, { defaultConfig }) {
let config = {
...defaultConfig,
// ...your configuration
}
const tamaguiPlugin = withTamagui({
config: './tamagui.config.ts',
components: ['tamagui'],
appDir: true,
})
return {
...config,
...tamaguiPlugin(config),
}
}

You need to pass the appDir boolean to @tamagui/next-plugin.

app/layout.tsx

Create a new component to add TamaguiProvider:

The internal usage of next/head is not supported in the app directory, so you need to add the skipNextHead prop to your <NextThemeProvider>.

NextTamaguiProvider.tsx

'use client'
import '@tamagui/core/reset.css'
import '@tamagui/polyfill-dev'
import { ReactNode } from 'react'
import { StyleSheet } from 'react-native'
import { useServerInsertedHTML } from 'next/navigation'
import { NextThemeProvider } from '@tamagui/next-theme'
import { TamaguiProvider } from 'tamagui'
import tamaguiConfig from '../tamagui.config'
export const NextTamaguiProvider = ({ children }: { children: ReactNode }) => {
useServerInsertedHTML(() => {
// @ts-ignore
const rnwStyle = StyleSheet.getSheet()
return (
<>
<style dangerouslySetInnerHTML={{ __html: rnwStyle.textContent }} id={rnwStyle.id} />
<style dangerouslySetInnerHTML={{ // the first time this runs you'll get the full CSS including all themes // after that, it will only return CSS generated since the last call __html: tamaguiConfig.getNewCSS(), }} />
</>
)
})
return (
<NextThemeProvider skipNextHead>
<TamaguiProvider config={tamaguiConfig} themeClassNameOnRoot>
{children}
</TamaguiProvider>
</NextThemeProvider>
)
}

The getNewCSS helper in Tamagui will keep track of the last call and only return new styles generated since the last usage.

Then add it to your app/layout.tsx:

layout.tsx

'use client'
import { NextTamaguiProvider } from './NextTamaguiProvider'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<NextTamaguiProvider>{children}</NextTamaguiProvider>
</body>
</html>
)
}

app/page.tsx

Now you're ready to start using adding components to app/page.tsx:

page.tsx

'use client'
import { Button } from 'tamagui'
export default function Home() {
return <Button>Hello world!</Button>
}

Themes

We've created a package that works with Tamagui to properly support SSR light/dark themes that also respect user system preference, called @tamagui/next-theme. It assumes your light/dark themes are named as such, but you can override it. This is pre-configured in the create-tamagui starter.

yarn @tamagui/next-theme

Here's how you'd set up your NextTamaguiProvider.tsx:

NextTamaguiProvider.tsx

'use client'
import '@tamagui/core/reset.css'
import '@tamagui/polyfill-dev'
import { ReactNode } from 'react'
import { StyleSheet } from 'react-native'
import { useServerInsertedHTML } from 'next/navigation'
import { NextThemeProvider, useRootTheme } from '@tamagui/next-theme'
import { TamaguiProvider } from 'tamagui'
import tamaguiConfig from '../tamagui.config'
export const NextTamaguiProvider = ({ children }: { children: ReactNode }) => {
const [theme, setTheme] = useRootTheme()
useServerInsertedHTML(() => {
// @ts-ignore
const rnwStyle = StyleSheet.getSheet()
return (
<>
<style dangerouslySetInnerHTML={{ __html: rnwStyle.textContent }} id={rnwStyle.id} />
<style dangerouslySetInnerHTML={{ __html: tamaguiConfig.getCSS({ // if you are using "outputCSS" option, you should use this "exclude" // if not, then you can leave the option out exclude: process.env.NODE_ENV === 'production' ? 'design-system' : null, }), }} />
</>
)
})
return (
<NextThemeProvider skipNextHead onChangeTheme={(next) => { setTheme(next as any) }} >
<TamaguiProvider config={tamaguiConfig} themeClassNameOnRoot defaultTheme={theme}>
{children}
</TamaguiProvider>
</NextThemeProvider>
)
}

If you need to access the current theme, say for a toggle button, you will then use the useThemeSetting hook. We'll release an update in the future that makes this automatically work better with Tamagui's built-in useThemeSetting.

SwitchThemeButton.tsx

import { useState } from 'react'
import { Button, YStack, useIsomorphicLayoutEffect } from 'tamagui'
import { useThemeSetting } from '@tamagui/next-theme'
export default function SwitchThemeButton() {
const themeSetting = useThemeSetting()
const [clientTheme, setClientTheme] = useState<string>('dark')
useIsomorphicLayoutEffect(() => {
setClientTheme(themeSetting.current || 'dark')
}, [themeSetting.current, themeSetting.resolvedTheme])
return (
<YStack ai="center" jc="center">
<Button onPress={themeSetting.toggle}>Change theme: {clientTheme}</Button>
</YStack>
)
}

Mount animations

Animations that run through JS drivers and have enterStyle set will want to add the following script that allows for hiding those animations before the JS runs. This is the "right way" to handle things, as it allows for disabling JS entirely and not accidentally hiding all unmounted things. Meanwhile, it still properly avoids a flash of mounted style for SSR pages.

Add this to useServerInsertedHTML in your NextTamaguiProvider.tsx:

NextTamaguiProvider.tsx

<script dangerouslySetInnerHTML={{ // avoid flash of entered elements before enter animations run: __html: `document.documentElement.classList.add('t_unmounted')`, }} />

Performance

Use outputCSS for better performance by shaking out all the js and generating only CSS styles at build-time.

Add outputCSS to your next.config.js:

next.config.js

const tamaguiPlugin = withTamagui({
config: './tamagui.config.ts',
components: ['tamagui'],
outputCSS: process.env.NODE_ENV === 'production' ? './public/tamagui.css' : null,
})

We recommend using this only for production so you get hot reloading and easier console logging during dev mode.

And then add this to include the CSS file generated at build-time:

page.tsx

if (process.env.NODE_ENV === 'production') {
require('../public/tamagui.css')
}

Add exclude option to useServerInsertedHTML in NextTamaguiProvider:

NextTamaguiProvider.tsx

<style dangerouslySetInnerHTML={{ __html: tamaguiConfig.getCSS({ // if you are using "outputCSS" option, you should use this "exclude" // if not, then you can leave the option out exclude: process.env.NODE_ENV === 'production' ? 'design-system' : null, }), }} />

Reseting CSS

There is an optional CSS reset to get more consistent styles across browsers, that helps normalize styling.

You can import it into your app like so:

_app.tsx

import '@tamagui/core/reset.css'

You will have to enable isCSSEnabled if you're using Metro as your web bundler. See Metro guide.

Previous

Developing

Next

Expo