---
title: Takumi
description: Integrate Takumi for framework agnostic and fast metadata image generation.
---

## Installation

```npm
npm install takumi-js
```

`takumi-js` loads a native binding (`@takumi-rs/core`) on Node.js. Keep it out of the server bundle for Next.js by marking it external:

```ts title="next.config.ts" tab="Next.js"
import type { NextConfig } from 'next';

const config: NextConfig = {
  // [!code ++]
  serverExternalPackages: ['@takumi-rs/core'],
};
```

There are no actions needed for Vite-based frameworks.

## Metadata Image

Generate metadata images dynamically with `takumi-js`.

Add the following under your loader, and define image metadata for pages:

```ts tab="Next.js" title="lib/source.ts"
// [!code ++:8]
export function getPageImageUrl(page: (typeof source)['$inferPage']) {
  const segments = [...page.slugs, 'image.webp'];

  return {
    segments,
    url: '/' + [page.locale, 'og', 'docs', ...segments].filter(Boolean).join('/'),
  };
}
```

```tsx tab="Next.js" title="app/docs/[[...slug]]/page.tsx"
import { notFound } from 'next/navigation';
import { source, getPageImageUrl } from '@/lib/source';
import type { Metadata } from 'next';

export async function generateMetadata(props: PageProps<'/docs/[[...slug]]'>): Promise<Metadata> {
  const params = await props.params;
  const page = source.getPage(params.slug);
  if (!page) notFound();

  return {
    title: page.data.title,
    description: page.data.description,
    openGraph: {
      // [!code ++]
      images: getPageImageUrl(page).url,
    },
  };
}
```

```tsx tab="React Router" title="app/lib/shared.ts"
export const docsImageRoute = '/og/docs';

// [!code ++:5]
export function getPageImagePath(slugs: string[], locale?: string) {
  return (
    '/' + [locale, ...docsImageRoute.split('/'), ...slugs, 'image.webp'].filter(Boolean).join('/')
  );
}
```

```tsx tab="React Router" title="app/routes/docs.tsx"
// [!code ++]
import { getPageImagePath } from '@/lib/shared';

// Somewhere in your loader
return {
  // [!code ++]
  imagePath: getPageImagePath(page.slugs, page.locale),
};

// Somewhere in your component or client loader
return (
  <>
    {/* [!code ++] */}
    <meta property="og:image" content={imagePath} />
  </>
);
```

```tsx tab="Vite Based Framework" title="app/lib/source.ts"
// [!code ++:5]
export function getPageImageUrl(page: (typeof source)['$inferPage']) {
  const segments = [...page.slugs, 'image.webp'];

  return '/' + [page.locale, 'og', 'docs', ...segments].filter(Boolean).join('/');
}
```

```tsx tab="Vite Based Framework" title="app/routes/docs.tsx"
// [!code ++]
import { getPageImageUrl } from '@/lib/source';

// Somewhere in your component or client loader
return (
  <>
    {/* [!code ++] */}
    <meta property="og:image" content={getPageImageUrl(page)} />
  </>
);
```

> We append `image.webp` to the end of slugs so that we can access it via `/og/docs/my-page/image.webp`, which results in smaller image sizes. You could use `image.png` if you prefer.

Finally, create a route handler to generate images at build time:

```tsx tab="Next.js" title="app/og/docs/[...slug]/route.tsx"
import { getPageImageUrl, source } from '@/lib/source';
import { notFound } from 'next/navigation';
import { ImageResponse } from 'takumi-js/response';
import { generate as DefaultImage } from 'fumadocs-ui/og/takumi';

export const revalidate = false;

export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) {
  const { slug } = await params;
  const page = source.getPage(slug.slice(0, -1));
  if (!page) notFound();

  return new ImageResponse(
    <DefaultImage title={page.data.title} description={page.data.description} site="My App" />,
    {
      width: 1200,
      height: 630,
      format: 'webp',
    },
  );
}

export function generateStaticParams() {
  return source.getPages().map((page) => ({
    lang: page.locale,
    slug: getPageImageUrl(page).segments,
  }));
}
```

```tsx tab="React Router" title="app/routes/docs.og.tsx"
import { ImageResponse } from 'takumi-js/response';
import { source } from '@/lib/source';
import { generate as DefaultImage } from 'fumadocs-ui/og/takumi';
import type { Route } from './+types/docs.og';

export function loader({ params }: Route.LoaderArgs) {
  const slugs = params['*']
    .split('/')
    .filter((v) => v.length > 0)
    .slice(0, -1);
  const page = source.getPage(slugs);

  if (!page) throw new Response(undefined, { status: 404 });

  return new ImageResponse(
    <DefaultImage title={page.data.title} description={page.data.description} site="My App" />,
    {
      width: 1200,
      height: 630,
      format: 'webp',
    },
  );
}
```

```tsx tab="Waku" title="src/pages/_api/og/docs/[...slugs]/image.webp.tsx"
import { source } from '@/lib/source';
import { ImageResponse } from 'takumi-js/response';
import { generate as DefaultImage } from 'fumadocs-ui/og/takumi';
import { ApiContext } from 'waku/router';

export async function GET(_: Request, { params }: ApiContext<'/og/docs/[...slugs]/image.webp'>) {
  const page = source.getPage(params.slugs);

  if (!page) return new Response(undefined, { status: 404 });

  return new ImageResponse(
    <DefaultImage title={page.data.title} description={page.data.description} site="My App" />,
    {
      width: 1200,
      height: 630,
      format: 'webp',
    },
  );
}

export async function getConfig() {
  const pages = source
    .generateParams()
    .map((item) => (item.lang ? [item.lang, ...item.slug] : item.slug));

  return {
    render: 'static' as const,
    staticPaths: pages,
  } as const;
}
```

```ts tab="React Router" title="app/routes.ts"
import { type RouteConfig, route } from '@react-router/dev/routes';

export default [
  // [!code ++]
  route('/og/docs/*', 'routes/docs.og.tsx'),
] satisfies RouteConfig;
```

```ts tab="React Router" title="react-router.config.ts"
import type { Config } from '@react-router/dev/config';
import { glob } from 'node:fs/promises';
import { createGetUrl, getSlugs } from 'fumadocs-core/source';
import { getPageImagePath } from './app/lib/shared';

const getUrl = createGetUrl('/docs');

export default {
  ssr: true,
  future: {
    v8_middleware: true,
  },
  // [!code ++:12]
  async prerender({ getStaticPaths }) {
    const paths = [...getStaticPaths()];

    for await (const entry of glob('**/*.mdx', { cwd: 'content/docs' })) {
      const slugs = getSlugs(entry);

      paths.push(getUrl(slugs));
      paths.push(getPageImagePath(slugs));
    }

    return paths;
  },
} satisfies Config;
```

> Takumi comes with pre-bundled full-axis (100-900) [`Geist`](https://vercel.com/font) and `Geist Mono` fonts, so you don't need to worry about it.

See [Takumi's Documentation](https://takumi.kane.tw/docs) for more details or advanced usage.

### Other Templates

There's other available templates, see [Takumi Templates](https://takumi.kane.tw/docs/templates) for a full list.
