Layouts and Pages
Create beautiful layouts and pages in VitNode plugins using Next.js App Router patterns.
Building layouts and pages in VitNode is just like Next.js - if you know Next.js, you're already a VitNode pro! Think of pages as rooms and layouts as the house structure that holds everything together.
VitNode automatically copies files from your plugin's src/routes/main
directory to the main application. No extra setup needed!
VitNode is i18n-first — render all user-facing text with translations
instead of hardcoded strings: getTranslations in Server Components and
useTranslations in Client Components. See Messages
and Namespaces.
Creating Pages
Pages are React components that live in page.tsx files. Each page represents a unique URL route.
Basic Page
Resolve text from your plugin's namespace with getTranslations:
import { getTranslations } from 'next-intl/server';
export default async function BlogHomePage() {
const t = await getTranslations('@vitnode/blog');
return (
<div className="py-12 text-center">
<h1 className="mb-4 text-4xl font-bold">{t('home.title')}</h1>
<p className="text-gray-600">{t('home.desc')}</p>
</div>
);
}Add the messages under your plugin's namespace:
{
"@vitnode/blog": {
"home": {
"title": "Welcome to Our Blog! 🚀",
"desc": "Where awesome content lives"
}
}
}Nested Routes
Create folders to organize your routes:
import { getTranslations } from 'next-intl/server';
export default async function DashboardPage() {
const t = await getTranslations('@vitnode/blog');
return (
<div>
<h1 className="text-3xl font-bold">{t('dashboard.title')}</h1>
<p>{t('dashboard.desc')}</p>
</div>
);
}This creates /dashboard route.
Dynamic Routes
Use [param] for dynamic URLs. Pass dynamic values straight into the message:
import { getTranslations } from 'next-intl/server';
interface PostPageProps {
params: Promise<{ slug: string }>;
}
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params;
const t = await getTranslations('@vitnode/blog');
// en.json: "post": { "title": "Post: {slug}" }
return (
<div>
<h1>{t('post.title', { slug })}</h1>
<p>{t('post.desc')}</p>
</div>
);
}Creating Layouts
Layouts wrap your pages and provide shared UI elements like headers, navigation, and footers.
Basic Layout
import { getTranslations } from 'next-intl/server';
export default async function BlogLayout({
children,
}: {
children: React.ReactNode;
}) {
const t = await getTranslations('@vitnode/blog');
return (
<div className="min-h-screen">
<header className="border-b bg-white shadow-sm">
<div className="container mx-auto px-4 py-4">
<h1 className="text-2xl font-bold text-indigo-600">
{t('layout.title')}
</h1>
</div>
</header>
<main className="container mx-auto px-4 py-8">{children}</main>
<footer className="bg-gray-900 py-8 text-white">
<div className="container mx-auto px-4 text-center">
<p>{t('layout.footer')}</p>
</div>
</footer>
</div>
);
}Nested Layout for Specific Sections
import { getTranslations } from 'next-intl/server';
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const t = await getTranslations('@vitnode/blog');
return (
<div className="flex">
{/* Sidebar */}
<aside className="min-h-screen w-64 border-r bg-gray-50">
<nav className="p-6">
<h2 className="mb-4 font-semibold">{t('dashboard.nav.title')}</h2>
<ul className="space-y-2">
<li>
<a href="/dashboard" className="text-gray-700 hover:text-indigo-600">
{t('dashboard.nav.overview')}
</a>
</li>
<li>
<a
href="/dashboard/posts"
className="text-gray-700 hover:text-indigo-600"
>
{t('dashboard.nav.posts')}
</a>
</li>
</ul>
</nav>
</aside>
{/* Main content */}
<div className="flex-1 p-8">{children}</div>
</div>
);
}Client Components
In a Client Component ('use client') use useTranslations instead of
getTranslations, and wrap it with I18nProvider to expose non-core.global
namespaces. See Namespaces.
Pages without a layout
Files in src/routes/main are wrapped by the main site layout (header, footer). To render a page without that chrome — only the root providers — put it in src/routes/blank instead. Handy for full-screen views, embeds, or standalone screens.
import { getTranslations } from 'next-intl/server';
export default async function StandalonePage() {
const t = await getTranslations('@vitnode/blog');
return (
<div className="flex min-h-screen items-center justify-center">
<h1>{t('standalone.title')}</h1>
</div>
);
}This creates a /standalone route that renders on its own. Admin pages live in src/routes/admin (see AdminCP Pages).
Adding Metadata
Improve SEO and social sharing with metadata — resolve the title/description from messages too:
import { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
interface PostPageProps {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({
params,
}: PostPageProps): Promise<Metadata> {
const { slug } = await params;
const t = await getTranslations('@vitnode/blog');
return {
title: t('post.meta.title', { slug }),
description: t('post.meta.desc', { slug }),
};
}
export default async function PostPage({ params }: PostPageProps) {
const { slug } = await params;
const t = await getTranslations('@vitnode/blog');
return (
<article>
<h1>{t('post.title', { slug })}</h1>
<p>{t('post.desc')}</p>
</article>
);
}