Initial commit

This commit is contained in:
2026-01-08 01:04:26 +00:00
committed by GitHub
commit 3ebf60d5dd
122 changed files with 39020 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
import { setRequestLocale } from 'next-intl/server';
export default async function CenteredLayout(props: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await props.params;
setRequestLocale(locale);
return (
<div className="flex min-h-screen items-center justify-center">
{props.children}
</div>
);
}

View File

@@ -0,0 +1,30 @@
import type { Metadata } from 'next';
import { SignIn } from '@clerk/nextjs';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { getI18nPath } from '@/utils/Helpers';
type ISignInPageProps = {
params: Promise<{ locale: string }>;
};
export async function generateMetadata(props: ISignInPageProps): Promise<Metadata> {
const { locale } = await props.params;
const t = await getTranslations({
locale,
namespace: 'SignIn',
});
return {
title: t('meta_title'),
description: t('meta_description'),
};
}
export default async function SignInPage(props: ISignInPageProps) {
const { locale } = await props.params;
setRequestLocale(locale);
return (
<SignIn path={getI18nPath('/sign-in', locale)} />
);
};

View File

@@ -0,0 +1,30 @@
import type { Metadata } from 'next';
import { SignUp } from '@clerk/nextjs';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { getI18nPath } from '@/utils/Helpers';
type ISignUpPageProps = {
params: Promise<{ locale: string }>;
};
export async function generateMetadata(props: ISignUpPageProps): Promise<Metadata> {
const { locale } = await props.params;
const t = await getTranslations({
locale,
namespace: 'SignUp',
});
return {
title: t('meta_title'),
description: t('meta_description'),
};
}
export default async function SignUpPage(props: ISignUpPageProps) {
const { locale } = await props.params;
setRequestLocale(locale);
return (
<SignUp path={getI18nPath('/sign-up', locale)} />
);
};

View File

@@ -0,0 +1,59 @@
import { SignOutButton } from '@clerk/nextjs';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Link from 'next/link';
import { LocaleSwitcher } from '@/components/LocaleSwitcher';
import { BaseTemplate } from '@/templates/BaseTemplate';
export default async function DashboardLayout(props: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await props.params;
setRequestLocale(locale);
const t = await getTranslations({
locale,
namespace: 'DashboardLayout',
});
return (
<BaseTemplate
leftNav={(
<>
<li>
<Link
href="/dashboard/"
className="border-none text-gray-700 hover:text-gray-900"
>
{t('dashboard_link')}
</Link>
</li>
<li>
<Link
href="/dashboard/user-profile/"
className="border-none text-gray-700 hover:text-gray-900"
>
{t('user_profile_link')}
</Link>
</li>
</>
)}
rightNav={(
<>
<li>
<SignOutButton>
<button className="border-none text-gray-700 hover:text-gray-900" type="button">
{t('sign_out')}
</button>
</SignOutButton>
</li>
<li>
<LocaleSwitcher />
</li>
</>
)}
>
{props.children}
</BaseTemplate>
);
}

View File

@@ -0,0 +1,25 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { Hello } from '@/components/Hello';
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await props.params;
const t = await getTranslations({
locale,
namespace: 'Dashboard',
});
return {
title: t('meta_title'),
};
}
export default function Dashboard() {
return (
<div className="py-5 [&_p]:my-6">
<Hello />
</div>
);
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from 'next';
import { UserProfile } from '@clerk/nextjs';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { getI18nPath } from '@/utils/Helpers';
type IUserProfilePageProps = {
params: Promise<{ locale: string }>;
};
export async function generateMetadata(props: IUserProfilePageProps): Promise<Metadata> {
const { locale } = await props.params;
const t = await getTranslations({
locale,
namespace: 'UserProfile',
});
return {
title: t('meta_title'),
};
}
export default async function UserProfilePage(props: IUserProfilePageProps) {
const { locale } = await props.params;
setRequestLocale(locale);
return (
<div className="my-6 -ml-16">
<UserProfile
path={getI18nPath('/dashboard/user-profile', locale)}
/>
</div>
);
};

View File

@@ -0,0 +1,41 @@
import { ClerkProvider } from '@clerk/nextjs';
import { setRequestLocale } from 'next-intl/server';
import { routing } from '@/libs/I18nRouting';
import { ClerkLocalizations } from '@/utils/AppConfig';
export default async function AuthLayout(props: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await props.params;
setRequestLocale(locale);
const clerkLocale = ClerkLocalizations.supportedLocales[locale] ?? ClerkLocalizations.defaultLocale;
let signInUrl = '/sign-in';
let signUpUrl = '/sign-up';
let dashboardUrl = '/dashboard';
let afterSignOutUrl = '/';
if (locale !== routing.defaultLocale) {
signInUrl = `/${locale}${signInUrl}`;
signUpUrl = `/${locale}${signUpUrl}`;
dashboardUrl = `/${locale}${dashboardUrl}`;
afterSignOutUrl = `/${locale}${afterSignOutUrl}`;
}
return (
<ClerkProvider
appearance={{
cssLayerName: 'clerk', // Ensure Clerk is compatible with Tailwind CSS v4
}}
localization={clerkLocale}
signInUrl={signInUrl}
signUpUrl={signUpUrl}
signInFallbackRedirectUrl={dashboardUrl}
signUpFallbackRedirectUrl={dashboardUrl}
afterSignOutUrl={afterSignOutUrl}
>
{props.children}
</ClerkProvider>
);
}

View File

@@ -0,0 +1,55 @@
import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Image from 'next/image';
type IAboutProps = {
params: Promise<{ locale: string }>;
};
export async function generateMetadata(props: IAboutProps): Promise<Metadata> {
const { locale } = await props.params;
const t = await getTranslations({
locale,
namespace: 'About',
});
return {
title: t('meta_title'),
description: t('meta_description'),
};
}
export default async function About(props: IAboutProps) {
const { locale } = await props.params;
setRequestLocale(locale);
const t = await getTranslations({
locale,
namespace: 'About',
});
return (
<>
<p>{t('about_paragraph')}</p>
<div className="mt-2 text-center text-sm">
{`${t('translation_powered_by')} `}
<a
className="text-blue-700 hover:border-b-2 hover:border-blue-700"
href="https://l.crowdin.com/next-js"
>
Crowdin
</a>
</div>
<a href="https://l.crowdin.com/next-js">
<Image
className="mx-auto mt-2"
src="/assets/images/crowdin-dark.png"
alt="Crowdin Translation Management System"
width={128}
height={26}
/>
</a>
</>
);
};

View File

@@ -0,0 +1,57 @@
import type { Metadata } from 'next';
import { useTranslations } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import Image from 'next/image';
import { CounterForm } from '@/components/CounterForm';
import { CurrentCount } from '@/components/CurrentCount';
export async function generateMetadata(props: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await props.params;
const t = await getTranslations({
locale,
namespace: 'Counter',
});
return {
title: t('meta_title'),
description: t('meta_description'),
};
}
export default function Counter() {
const t = useTranslations('Counter');
return (
<>
<CounterForm />
<div className="mt-3">
<CurrentCount />
</div>
<div className="mt-5 text-center text-sm">
{`${t('security_powered_by')} `}
<a
className="text-blue-700 hover:border-b-2 hover:border-blue-700"
href="https://launch.arcjet.com/Q6eLbRE"
>
Arcjet
</a>
</div>
<a
href="https://launch.arcjet.com/Q6eLbRE"
>
<Image
className="mx-auto mt-2"
src="/assets/images/arcjet-light.svg"
alt="Arcjet"
width={128}
height={38}
/>
</a>
</>
);
};

View File

@@ -0,0 +1,96 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Link from 'next/link';
import { DemoBanner } from '@/components/DemoBanner';
import { LocaleSwitcher } from '@/components/LocaleSwitcher';
import { BaseTemplate } from '@/templates/BaseTemplate';
export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await props.params;
setRequestLocale(locale);
const t = await getTranslations({
locale,
namespace: 'RootLayout',
});
return (
<>
<DemoBanner />
<BaseTemplate
leftNav={(
<>
<li>
<Link
href="/"
className="border-none text-gray-700 hover:text-gray-900"
>
{t('home_link')}
</Link>
</li>
<li>
<Link
href="/about/"
className="border-none text-gray-700 hover:text-gray-900"
>
{t('about_link')}
</Link>
</li>
<li>
<Link
href="/counter/"
className="border-none text-gray-700 hover:text-gray-900"
>
{t('counter_link')}
</Link>
</li>
<li>
<Link
href="/portfolio/"
className="border-none text-gray-700 hover:text-gray-900"
>
{t('portfolio_link')}
</Link>
</li>
<li>
<a
className="border-none text-gray-700 hover:text-gray-900"
href="https://github.com/ixartz/Next-js-Boilerplate"
>
GitHub
</a>
</li>
</>
)}
rightNav={(
<>
<li>
<Link
href="/sign-in/"
className="border-none text-gray-700 hover:text-gray-900"
>
{t('sign_in_link')}
</Link>
</li>
<li>
<Link
href="/sign-up/"
className="border-none text-gray-700 hover:text-gray-900"
>
{t('sign_up_link')}
</Link>
</li>
<li>
<LocaleSwitcher />
</li>
</>
)}
>
<div className="py-5 text-xl [&_p]:my-6">{props.children}</div>
</BaseTemplate>
</>
);
}

View File

@@ -0,0 +1,134 @@
import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Sponsors } from '@/components/Sponsors';
type IIndexProps = {
params: Promise<{ locale: string }>;
};
export async function generateMetadata(props: IIndexProps): Promise<Metadata> {
const { locale } = await props.params;
const t = await getTranslations({
locale,
namespace: 'Index',
});
return {
title: t('meta_title'),
description: t('meta_description'),
};
}
export default async function Index(props: IIndexProps) {
const { locale } = await props.params;
setRequestLocale(locale);
const t = await getTranslations({
locale,
namespace: 'Index',
});
return (
<>
<p>
{`Follow `}
<a
className="text-blue-700 hover:border-b-2 hover:border-blue-700"
href="https://twitter.com/ixartz"
target="_blank"
rel="noreferrer noopener"
>
@Ixartz on Twitter
</a>
{` for updates and more information about the boilerplate.`}
</p>
<h2 className="mt-5 text-2xl font-bold">
Boilerplate Code for Your Next.js Project with Tailwind CSS
</h2>
<p className="text-base">
Next.js Boilerplate is a developer-friendly starter code for Next.js projects, built with Tailwind CSS and TypeScript.
{' '}
<span role="img" aria-label="zap">
</span>
{' '}
Designed with developer experience in mind, it includes:
</p>
<ul className="mt-3 text-base">
<li>🚀 Next.js with App Router support</li>
<li>🔥 TypeScript for type checking</li>
<li>💎 Tailwind CSS integration</li>
<li>
🔒 Authentication with
{' '}
<a
className="font-bold text-blue-700 hover:border-b-2 hover:border-blue-700"
href="https://clerk.com?utm_source=github&amp;utm_medium=sponsorship&amp;utm_campaign=nextjs-boilerplate"
>
Clerk
</a>
{' '}
(includes passwordless, social, and multi-factor auth)
</li>
<li>📦 ORM with DrizzleORM (PostgreSQL, SQLite, MySQL support)</li>
<li>
💽 Dev database with PGlite and production with Neon (PostgreSQL)
</li>
<li>
🌐 Multi-language support (i18n) with next-intl and
{' '}
<a
className="font-bold text-blue-700 hover:border-b-2 hover:border-blue-700"
href="https://l.crowdin.com/next-js"
>
Crowdin
</a>
</li>
<li>🔴 Form handling (React Hook Form) and validation (Zod)</li>
<li>📏 Linting and formatting (ESLint, Prettier)</li>
<li>🦊 Git hooks and commit linting (Husky, Commitlint)</li>
<li>🦺 Testing suite (Vitest, React Testing Library, Playwright)</li>
<li>🎉 Storybook for UI development</li>
<li>
🐰 AI-powered code reviews with
{' '}
<a
className="font-bold text-blue-700 hover:border-b-2 hover:border-blue-700"
href="https://www.coderabbit.ai?utm_source=next_js_starter&utm_medium=github&utm_campaign=next_js_starter_oss_2025"
>
CodeRabbit
</a>
</li>
<li>
🚨 Error monitoring (
<a
className="font-bold text-blue-700 hover:border-b-2 hover:border-blue-700"
href="https://sentry.io/for/nextjs/?utm_source=github&amp;utm_medium=paid-community&amp;utm_campaign=general-fy25q1-nextjs&amp;utm_content=github-banner-nextjsboilerplate-logo"
>
Sentry
</a>
) and logging (LogTape, an alternative to Pino.js)
</li>
<li>🖥 Monitoring as Code (Checkly)</li>
<li>
🔐 Security and bot protection (
<a
className="font-bold text-blue-700 hover:border-b-2 hover:border-blue-700"
href="https://launch.arcjet.com/Q6eLbRE"
>
Arcjet
</a>
)
</li>
<li>🤖 SEO optimization (metadata, JSON-LD, Open Graph tags)</li>
<li> Development tools (VSCode config, bundler analyzer, changelog generation)</li>
</ul>
<p className="text-base">
Our sponsors&apos; exceptional support has made this project possible.
Their services integrate seamlessly with the boilerplate, and we
recommend trying them out.
</p>
<h2 className="mt-5 text-2xl font-bold">{t('sponsors_title')}</h2>
<Sponsors />
</>
);
};

View File

@@ -0,0 +1,72 @@
import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Image from 'next/image';
import { routing } from '@/libs/I18nRouting';
type IPortfolioDetailProps = {
params: Promise<{ slug: string; locale: string }>;
};
export function generateStaticParams() {
return routing.locales
.map(locale =>
Array.from(Array.from({ length: 6 }).keys()).map(elt => ({
slug: `${elt}`,
locale,
})),
)
.flat(1);
}
export async function generateMetadata(props: IPortfolioDetailProps): Promise<Metadata> {
const { locale, slug } = await props.params;
const t = await getTranslations({
locale,
namespace: 'PortfolioSlug',
});
return {
title: t('meta_title', { slug }),
description: t('meta_description', { slug }),
};
}
export default async function PortfolioDetail(props: IPortfolioDetailProps) {
const { locale, slug } = await props.params;
setRequestLocale(locale);
const t = await getTranslations({
locale,
namespace: 'PortfolioSlug',
});
return (
<>
<h1 className="capitalize">{t('header', { slug })}</h1>
<p>{t('content')}</p>
<div className="mt-5 text-center text-sm">
{`${t('code_review_powered_by')} `}
<a
className="text-blue-700 hover:border-b-2 hover:border-blue-700"
href="https://www.coderabbit.ai?utm_source=next_js_starter&utm_medium=github&utm_campaign=next_js_starter_oss_2025"
>
CodeRabbit
</a>
</div>
<a
href="https://www.coderabbit.ai?utm_source=next_js_starter&utm_medium=github&utm_campaign=next_js_starter_oss_2025"
>
<Image
className="mx-auto mt-2"
src="/assets/images/coderabbit-logo-light.svg"
alt="CodeRabbit"
width={128}
height={22}
/>
</a>
</>
);
};
export const dynamicParams = false;

View File

@@ -0,0 +1,77 @@
import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Image from 'next/image';
import Link from 'next/link';
type IPortfolioProps = {
params: Promise<{ locale: string }>;
};
export async function generateMetadata(props: IPortfolioProps): Promise<Metadata> {
const { locale } = await props.params;
const t = await getTranslations({
locale,
namespace: 'Portfolio',
});
return {
title: t('meta_title'),
description: t('meta_description'),
};
}
export default async function Portfolio(props: IPortfolioProps) {
const { locale } = await props.params;
setRequestLocale(locale);
const t = await getTranslations({
locale,
namespace: 'Portfolio',
});
return (
<>
<p>{t('presentation')}</p>
<div className="grid grid-cols-1 justify-items-start gap-3 md:grid-cols-2 xl:grid-cols-3">
{Array.from(Array.from({ length: 6 }).keys()).map(elt => (
<Link
className="hover:text-blue-700"
key={elt}
href={`/portfolio/${elt}`}
>
{t('portfolio_name', { name: elt })}
</Link>
))}
</div>
<div className="mt-5 text-center text-sm">
{`${t('error_reporting_powered_by')} `}
<a
className="text-blue-700 hover:border-b-2 hover:border-blue-700"
href="https://sentry.io/for/nextjs/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy25q1-nextjs&utm_content=github-banner-nextjsboilerplate-logo"
>
Sentry
</a>
{` - ${t('coverage_powered_by')} `}
<a
className="text-blue-700 hover:border-b-2 hover:border-blue-700"
href="https://about.codecov.io/codecov-free-trial/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy25q1-nextjs&utm_content=github-banner-nextjsboilerplate-logo"
>
Codecov
</a>
</div>
<a
href="https://sentry.io/for/nextjs/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy25q1-nextjs&utm_content=github-banner-nextjsboilerplate-logo"
>
<Image
className="mx-auto mt-2"
src="/assets/images/sentry-dark.png"
alt="Sentry"
width={128}
height={38}
/>
</a>
</>
);
};

View File

@@ -0,0 +1,36 @@
import { sql } from 'drizzle-orm';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import * as z from 'zod';
import { db } from '@/libs/DB';
import { logger } from '@/libs/Logger';
import { counterSchema } from '@/models/Schema';
import { CounterValidation } from '@/validations/CounterValidation';
export const PUT = async (request: Request) => {
const json = await request.json();
const parse = CounterValidation.safeParse(json);
if (!parse.success) {
return NextResponse.json(z.treeifyError(parse.error), { status: 422 });
}
// `x-e2e-random-id` is used for end-to-end testing to make isolated requests
// The default value is 0 when there is no `x-e2e-random-id` header
const id = Number((await headers()).get('x-e2e-random-id')) || 0;
const count = await db
.insert(counterSchema)
.values({ id, count: parse.data.increment })
.onConflictDoUpdate({
target: counterSchema.id,
set: { count: sql`${counterSchema.count} + ${parse.data.increment}` },
})
.returning();
logger.info('Counter has been incremented');
return NextResponse.json({
count: count[0]?.count,
});
};

View File

@@ -0,0 +1,64 @@
import type { Metadata } from 'next';
import { hasLocale, NextIntlClientProvider } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { PostHogProvider } from '@/components/analytics/PostHogProvider';
import { DemoBadge } from '@/components/DemoBadge';
import { routing } from '@/libs/I18nRouting';
import '@/styles/global.css';
export const metadata: Metadata = {
icons: [
{
rel: 'apple-touch-icon',
url: '/apple-touch-icon.png',
},
{
rel: 'icon',
type: 'image/png',
sizes: '32x32',
url: '/favicon-32x32.png',
},
{
rel: 'icon',
type: 'image/png',
sizes: '16x16',
url: '/favicon-16x16.png',
},
{
rel: 'icon',
url: '/favicon.ico',
},
],
};
export function generateStaticParams() {
return routing.locales.map(locale => ({ locale }));
}
export default async function RootLayout(props: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await props.params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
setRequestLocale(locale);
return (
<html lang={locale}>
<body>
<NextIntlClientProvider>
<PostHogProvider>
{props.children}
</PostHogProvider>
<DemoBadge />
</NextIntlClientProvider>
</body>
</html>
);
}

26
src/app/global-error.tsx Normal file
View File

@@ -0,0 +1,26 @@
'use client';
import * as Sentry from '@sentry/nextjs';
import NextError from 'next/error';
import { useEffect } from 'react';
import { routing } from '@/libs/I18nRouting';
export default function GlobalError(props: {
error: Error & { digest?: string };
}) {
useEffect(() => {
Sentry.captureException(props.error);
}, [props.error]);
return (
<html lang={routing.defaultLocale}>
<body>
{/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
<NextError statusCode={0} />
</body>
</html>
);
}

13
src/app/robots.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { MetadataRoute } from 'next';
import { getBaseUrl } from '@/utils/Helpers';
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: '/dashboard',
},
sitemap: `${getBaseUrl()}/sitemap.xml`,
};
}

14
src/app/sitemap.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { MetadataRoute } from 'next';
import { getBaseUrl } from '@/utils/Helpers';
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: `${getBaseUrl()}/`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.7,
},
// Add more URLs here
];
}

View File

@@ -0,0 +1,64 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { CounterValidation } from '@/validations/CounterValidation';
export const CounterForm = () => {
const t = useTranslations('CounterForm');
const form = useForm({
resolver: zodResolver(CounterValidation),
defaultValues: {
increment: 1,
},
});
const router = useRouter();
const handleIncrement = form.handleSubmit(async (data) => {
const response = await fetch(`/api/counter`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
await response.json();
router.refresh();
});
return (
<form onSubmit={handleIncrement}>
<p>{t('presentation')}</p>
<div>
<label className="text-sm font-bold text-gray-700" htmlFor="increment">
{t('label_increment')}
<input
id="increment"
type="number"
className="ml-2 w-32 appearance-none rounded-sm border border-gray-200 px-2 py-1 text-sm leading-tight text-gray-700 focus:ring-3 focus:ring-blue-300/50 focus:outline-hidden"
{...form.register('increment', { valueAsNumber: true })}
/>
</label>
{form.formState.errors.increment && (
<div className="my-2 text-xs text-red-500 italic">
{t('error_increment_range')}
</div>
)}
</div>
<div className="mt-2">
<button
className="rounded-sm bg-blue-500 px-5 py-1 font-bold text-white hover:bg-blue-600 focus:ring-3 focus:ring-blue-300/50 focus:outline-hidden disabled:pointer-events-none disabled:opacity-50"
type="submit"
disabled={form.formState.isSubmitting}
>
{t('button_increment')}
</button>
</div>
</form>
);
};

View File

@@ -0,0 +1,26 @@
import { eq } from 'drizzle-orm';
import { getTranslations } from 'next-intl/server';
import { headers } from 'next/headers';
import { db } from '@/libs/DB';
import { logger } from '@/libs/Logger';
import { counterSchema } from '@/models/Schema';
export const CurrentCount = async () => {
const t = await getTranslations('CurrentCount');
// `x-e2e-random-id` is used for end-to-end testing to make isolated requests
// The default value is 0 when there is no `x-e2e-random-id` header
const id = Number((await headers()).get('x-e2e-random-id')) || 0;
const result = await db.query.counterSchema.findFirst({
where: eq(counterSchema.id, id),
});
const count = result?.count ?? 0;
logger.info('Counter fetched successfully');
return (
<div>
{t('count', { count })}
</div>
);
};

View File

@@ -0,0 +1,12 @@
export const DemoBadge = () => (
<div className="fixed right-20 bottom-0 z-10">
<a
href="https://github.com/ixartz/Next-js-Boilerplate"
>
<div className="rounded-md bg-gray-900 px-3 py-2 font-semibold text-gray-100">
<span className="text-gray-500">Demo of</span>
{` Next.js Boilerplate`}
</div>
</a>
</div>
);

View File

@@ -0,0 +1,9 @@
import Link from 'next/link';
export const DemoBanner = () => (
<div className="sticky top-0 z-50 bg-gray-900 p-4 text-center text-lg font-semibold text-gray-100 [&_a]:text-fuchsia-500 [&_a:hover]:text-indigo-500">
Live Demo of Next.js Boilerplate -
{' '}
<Link href="/sign-up">Explore the Authentication</Link>
</div>
);

30
src/components/Hello.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { currentUser } from '@clerk/nextjs/server';
import { getTranslations } from 'next-intl/server';
import { Sponsors } from './Sponsors';
export const Hello = async () => {
const t = await getTranslations('Dashboard');
const user = await currentUser();
return (
<>
<p>
{`👋 `}
{t('hello_message', { email: user?.primaryEmailAddress?.emailAddress ?? '' })}
</p>
<p>
{t.rich('alternative_message', {
url: () => (
<a
className="text-blue-700 hover:border-b-2 hover:border-blue-700"
href="https://nextjs-boilerplate.com/pro-saas-starter-kit"
>
Next.js Boilerplate Pro
</a>
),
})}
</p>
<Sponsors />
</>
);
};

View File

@@ -0,0 +1,33 @@
'use client';
import type { ChangeEventHandler } from 'react';
import { useLocale } from 'next-intl';
import { useRouter } from 'next/navigation';
import { usePathname } from '@/libs/I18nNavigation';
import { routing } from '@/libs/I18nRouting';
export const LocaleSwitcher = () => {
const router = useRouter();
const pathname = usePathname();
const locale = useLocale();
const handleChange: ChangeEventHandler<HTMLSelectElement> = (event) => {
router.push(`/${event.target.value}${pathname}`);
router.refresh(); // Ensure the page takes the new locale into account related to the issue #395
};
return (
<select
defaultValue={locale}
onChange={handleChange}
className="border border-gray-300 font-medium focus:outline-hidden focus-visible:ring-3"
aria-label="lang-switcher"
>
{routing.locales.map(elt => (
<option key={elt} value={elt}>
{elt.toUpperCase()}
</option>
))}
</select>
);
};

136
src/components/Sponsors.tsx Normal file
View File

@@ -0,0 +1,136 @@
import Image from 'next/image';
export const Sponsors = () => (
<table className="border-collapse">
<tbody>
<tr className="h-56">
<td className="border-2 border-gray-300 p-3">
<a
href="https://clerk.com?utm_source=github&utm_medium=sponsorship&utm_campaign=nextjs-boilerplate"
target="_blank"
rel="noopener"
>
<Image
src="/assets/images/clerk-logo-dark.png"
alt="Clerk Authentication & User Management for Next.js"
width={260}
height={224}
/>
</a>
</td>
<td className="border-2 border-gray-300 p-3">
<a href="https://www.coderabbit.ai?utm_source=next_js_starter&utm_medium=github&utm_campaign=next_js_starter_oss_2025" target="_blank" rel="noopener">
<Image
src="/assets/images/coderabbit-logo-light.svg"
alt="CodeRabbit"
width={260}
height={224}
/>
</a>
</td>
<td className="border-2 border-gray-300 p-3">
<a
href="https://sentry.io/for/nextjs/?utm_source=github&utm_medium=paid-community&utm_campaign=general-fy25q1-nextjs&utm_content=github-banner-nextjsboilerplate-logo"
target="_blank"
rel="noopener"
>
<Image
src="/assets/images/sentry-dark.png"
alt="Sentry"
width={260}
height={224}
/>
</a>
</td>
</tr>
<tr className="h-56">
<td className="border-2 border-gray-300 p-3">
<a href="https://launch.arcjet.com/Q6eLbRE">
<Image
src="/assets/images/arcjet-light.svg"
alt="Arcjet"
width={260}
height={224}
/>
</a>
</td>
<td className="border-2 border-gray-300 p-3">
<a href="https://sevalla.com/">
<Image
src="/assets/images/sevalla-light.png"
alt="Sevalla"
width={260}
height={224}
/>
</a>
</td>
<td className="border-2 border-gray-300 p-3">
<a href="https://l.crowdin.com/next-js" target="_blank" rel="noopener">
<Image
src="/assets/images/crowdin-dark.png"
alt="Crowdin"
width={260}
height={224}
/>
</a>
</td>
</tr>
<tr className="h-56">
<td className="border-2 border-gray-300 p-3">
<a
href="https://betterstack.com/?utm_source=github&utm_medium=sponsorship&utm_campaign=next-js-boilerplate"
target="_blank"
rel="noopener"
>
<Image
src="/assets/images/better-stack-dark.png"
alt="Better Stack"
width={260}
height={224}
/>
</a>
</td>
<td className="border-2 border-gray-300 p-3">
<a
href="https://posthog.com/?utm_source=github&utm_medium=sponsorship&utm_campaign=next-js-boilerplate"
target="_blank"
rel="noopener"
>
<Image
src="https://posthog.com/brand/posthog-logo.svg"
alt="PostHog"
width={260}
height={224}
/>
</a>
</td>
<td className="border-2 border-gray-300 p-3">
<a
href="https://www.checklyhq.com/?utm_source=github&utm_medium=sponsorship&utm_campaign=next-js-boilerplate"
target="_blank"
rel="noopener"
>
<Image
src="/assets/images/checkly-logo-light.png"
alt="Checkly"
width={260}
height={224}
/>
</a>
</td>
</tr>
<tr className="h-56">
<td className="border-2 border-gray-300 p-3">
<a href="https://nextjs-boilerplate.com/pro-saas-starter-kit">
<Image
src="/assets/images/nextjs-boilerplate-saas.png"
alt="Next.js SaaS Boilerplate"
width={260}
height={224}
/>
</a>
</td>
</tr>
</tbody>
</table>
);

View File

@@ -0,0 +1,36 @@
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
import { usePostHog } from 'posthog-js/react';
import { Suspense, useEffect } from 'react';
const PostHogPageView = () => {
const pathname = usePathname();
const searchParams = useSearchParams();
const posthog = usePostHog();
// Track pageviews
useEffect(() => {
if (pathname && posthog) {
let url = window.origin + pathname;
if (searchParams.toString()) {
url = `${url}?${searchParams.toString()}`;
}
posthog.capture('$pageview', { $current_url: url });
}
}, [pathname, searchParams, posthog]);
return null;
};
// Wrap this in Suspense to avoid the `useSearchParams` usage above
// from de-opting the whole app into client-side rendering
// See: https://nextjs.org/docs/messages/deopted-into-client-rendering
export const SuspendedPostHogPageView = () => {
return (
<Suspense fallback={null}>
<PostHogPageView />
</Suspense>
);
};

View File

@@ -0,0 +1,30 @@
'use client';
import posthog from 'posthog-js';
import { PostHogProvider as PHProvider } from 'posthog-js/react';
import { useEffect } from 'react';
import { Env } from '@/libs/Env';
import { SuspendedPostHogPageView } from './PostHogPageView';
export const PostHogProvider = (props: { children: React.ReactNode }) => {
useEffect(() => {
if (Env.NEXT_PUBLIC_POSTHOG_KEY) {
posthog.init(Env.NEXT_PUBLIC_POSTHOG_KEY, {
api_host: Env.NEXT_PUBLIC_POSTHOG_HOST,
capture_pageview: false, // Disable automatic pageview capture, as we capture manually
capture_pageleave: true, // Enable pageleave capture
});
}
}, []);
if (!Env.NEXT_PUBLIC_POSTHOG_KEY) {
return props.children;
}
return (
<PHProvider client={posthog}>
<SuspendedPostHogPageView />
{props.children}
</PHProvider>
);
};

View File

@@ -0,0 +1,43 @@
// This file configures the initialization of Sentry on the client.
// The added config here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from '@sentry/nextjs';
if (!process.env.NEXT_PUBLIC_SENTRY_DISABLED) {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Add optional integrations for additional features
integrations: [
Sentry.replayIntegration(),
Sentry.consoleLoggingIntegration(),
Sentry.browserTracingIntegration(),
...(process.env.NODE_ENV === 'development'
? [Sentry.spotlightBrowserIntegration()]
: []),
],
// Adds request headers and IP for users, for more info visit
sendDefaultPii: true,
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: 1,
// Define how likely Replay events are sampled.
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
// Define how likely Replay events are sampled when an error occurs.
replaysOnErrorSampleRate: 1.0,
// Enable logs to be sent to Sentry
enableLogs: true,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});
}
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

41
src/instrumentation.ts Normal file
View File

@@ -0,0 +1,41 @@
import * as Sentry from '@sentry/nextjs';
const sentryOptions: Sentry.NodeOptions | Sentry.EdgeOptions = {
// Sentry DSN
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Enable Spotlight in development
spotlight: process.env.NODE_ENV === 'development',
integrations: [
Sentry.consoleLoggingIntegration(),
],
// Adds request headers and IP for users, for more info visit
sendDefaultPii: true,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// Enable logs to be sent to Sentry
enableLogs: true,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
};
export async function register() {
if (!process.env.NEXT_PUBLIC_SENTRY_DISABLED) {
if (process.env.NEXT_RUNTIME === 'nodejs') {
// Node.js Sentry configuration
Sentry.init(sentryOptions);
}
if (process.env.NEXT_RUNTIME === 'edge') {
// Edge Sentry configuration
Sentry.init(sentryOptions);
}
}
}
export const onRequestError = Sentry.captureRequestError;

17
src/libs/Arcjet.ts Normal file
View File

@@ -0,0 +1,17 @@
import arcjet, { shield } from '@arcjet/next';
// Create a base Arcjet instance which can be imported and extended in each route.
export default arcjet({
// Get your site key from https://launch.arcjet.com/Q6eLbRE
// Use `process.env` instead of Env to reduce bundle size in middleware
key: process.env.ARCJET_KEY ?? '',
// Identify the user by their IP address
characteristics: ['ip.src'],
rules: [
// Protect against common attacks with Arcjet Shield
shield({
mode: 'LIVE', // will block requests. Use "DRY_RUN" to log only
}),
// Other rules are added in different routes
],
});

18
src/libs/DB.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type * as schema from '@/models/Schema';
import { createDbConnection } from '@/utils/DBConnection';
import { Env } from './Env';
// Stores the db connection in the global scope to prevent multiple instances due to hot reloading with Next.js
const globalForDb = globalThis as unknown as {
drizzle: NodePgDatabase<typeof schema>;
};
const db = globalForDb.drizzle || createDbConnection();
// Only store in global during development to prevent hot reload issues
if (Env.NODE_ENV !== 'production') {
globalForDb.drizzle = db;
}
export { db };

35
src/libs/Env.ts Normal file
View File

@@ -0,0 +1,35 @@
import { createEnv } from '@t3-oss/env-nextjs';
import * as z from 'zod';
export const Env = createEnv({
server: {
ARCJET_KEY: z.string().startsWith('ajkey_').optional(),
CLERK_SECRET_KEY: z.string().min(1),
DATABASE_URL: z.string().min(1),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().optional(),
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1),
NEXT_PUBLIC_BETTER_STACK_SOURCE_TOKEN: z.string().optional(),
NEXT_PUBLIC_BETTER_STACK_INGESTING_HOST: z.string().optional(),
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
},
shared: {
NODE_ENV: z.enum(['test', 'development', 'production']).optional(),
},
// You need to destructure all the keys manually
runtimeEnv: {
ARCJET_KEY: process.env.ARCJET_KEY,
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
DATABASE_URL: process.env.DATABASE_URL,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
NEXT_PUBLIC_BETTER_STACK_SOURCE_TOKEN: process.env.NEXT_PUBLIC_BETTER_STACK_SOURCE_TOKEN,
NEXT_PUBLIC_BETTER_STACK_INGESTING_HOST: process.env.NEXT_PUBLIC_BETTER_STACK_INGESTING_HOST,
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
NODE_ENV: process.env.NODE_ENV,
},
});

26
src/libs/I18n.ts Normal file
View File

@@ -0,0 +1,26 @@
import { hasLocale } from 'next-intl';
import { getRequestConfig } from 'next-intl/server';
import { routing } from './I18nRouting';
// NextJS Boilerplate uses Crowdin as the localization software.
// As a developer, you only need to take care of the English (or another default language) version.
// Other languages are automatically generated and handled by Crowdin.
// The localisation files are synced with Crowdin using GitHub Actions.
// By default, there are 3 ways to sync the message files:
// 1. Automatically sync on push to the `main` branch
// 2. Run manually the workflow on GitHub Actions
// 3. Every 24 hours at 5am, the workflow will run automatically
export default getRequestConfig(async ({ requestLocale }) => {
// Typically corresponds to the `[locale]` segment
const requested = await requestLocale;
const locale = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale;
return {
locale,
messages: (await import(`../locales/${locale}.json`)).default,
};
});

View File

@@ -0,0 +1,4 @@
import { createNavigation } from 'next-intl/navigation';
import { routing } from './I18nRouting';
export const { usePathname } = createNavigation(routing);

8
src/libs/I18nRouting.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineRouting } from 'next-intl/routing';
import { AppConfig } from '@/utils/AppConfig';
export const routing = defineRouting({
locales: AppConfig.locales,
localePrefix: AppConfig.localePrefix,
defaultLocale: AppConfig.defaultLocale,
});

33
src/libs/Logger.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { AsyncSink } from '@logtape/logtape';
import { configure, fromAsyncSink, getConsoleSink, getJsonLinesFormatter, getLogger } from '@logtape/logtape';
import { Env } from './Env';
const betterStackSink: AsyncSink = async (record) => {
await fetch(`https://${Env.NEXT_PUBLIC_BETTER_STACK_INGESTING_HOST}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${Env.NEXT_PUBLIC_BETTER_STACK_SOURCE_TOKEN}`,
},
body: JSON.stringify(record),
});
};
await configure({
sinks: {
console: getConsoleSink({ formatter: getJsonLinesFormatter() }),
betterStack: fromAsyncSink(betterStackSink),
},
loggers: [
{ category: ['logtape', 'meta'], sinks: ['console'], lowestLevel: 'warning' },
{
category: ['app'],
sinks: Env.NEXT_PUBLIC_BETTER_STACK_SOURCE_TOKEN && Env.NEXT_PUBLIC_BETTER_STACK_INGESTING_HOST
? ['console', 'betterStack']
: ['console'],
lowestLevel: 'debug',
},
],
});
export const logger = getLogger(['app']);

75
src/locales/en.json Normal file
View File

@@ -0,0 +1,75 @@
{
"RootLayout": {
"home_link": "Home",
"about_link": "About",
"counter_link": "Counter",
"portfolio_link": "Portfolio",
"sign_in_link": "Sign in",
"sign_up_link": "Sign up"
},
"BaseTemplate": {
"description": "Starter code for your Nextjs Boilerplate with Tailwind CSS",
"made_with": "Made with <author></author>."
},
"Index": {
"meta_title": "Next.js Boilerplate Presentation",
"meta_description": "Next js Boilerplate is the perfect starter code for your project. Build your React application with the Next.js framework.",
"sponsors_title": "Sponsors"
},
"Counter": {
"meta_title": "Counter",
"meta_description": "An example of DB operation",
"security_powered_by": "Security, bot detection and rate limiting powered by"
},
"CounterForm": {
"presentation": "The counter is stored in the database and incremented by the value you provide.",
"label_increment": "Increment by",
"button_increment": "Increment",
"error_increment_range": "Value must be between 1 and 3"
},
"CurrentCount": {
"count": "Count: {count}"
},
"About": {
"meta_title": "About",
"meta_description": "About page description",
"about_paragraph": "Welcome to our About page! We are a team of passionate individuals dedicated to creating amazing software.",
"translation_powered_by": "Translation powered by"
},
"Portfolio": {
"meta_title": "Portfolio",
"meta_description": "Welcome to my portfolio page!",
"presentation": "Welcome to my portfolio page! Here you will find a carefully curated collection of my work and accomplishments. Through this portfolio, I'm to showcase my expertise, creativity, and the value I can bring to your projects.",
"portfolio_name": "Portfolio {name}",
"error_reporting_powered_by": "Error reporting powered by",
"coverage_powered_by": "Code coverage powered by"
},
"PortfolioSlug": {
"meta_title": "Portfolio {slug}",
"meta_description": "Portfolio {slug} description",
"header": "Portfolio {slug}",
"content": "Created a set of promotional materials and branding elements for a corporate event. Crafted a visually unified theme, encompassing a logo, posters, banners, and digital assets. Integrated the client's brand identity while infusing it with a contemporary and innovative approach.",
"code_review_powered_by": "Code review powered by"
},
"SignIn": {
"meta_title": "Sign in",
"meta_description": "Seamlessly sign in to your account with our user-friendly login process."
},
"SignUp": {
"meta_title": "Sign up",
"meta_description": "Effortlessly create an account through our intuitive sign-up process."
},
"Dashboard": {
"meta_title": "Dashboard",
"hello_message": "Hello {email}!",
"alternative_message": "Need advanced features? Multi-tenancy & Teams, Roles & Permissions, Shadcn UI, End-to-End Typesafety with oRPC, Stripe Payment, Light / Dark mode. Try <url></url>."
},
"UserProfile": {
"meta_title": "User Profile"
},
"DashboardLayout": {
"dashboard_link": "Dashboard",
"user_profile_link": "Manage your account",
"sign_out": "Sign out"
}
}

75
src/locales/fr.json Normal file
View File

@@ -0,0 +1,75 @@
{
"RootLayout": {
"home_link": "Accueil",
"about_link": "A propos",
"counter_link": "Compteur",
"portfolio_link": "Portfolio",
"sign_in_link": "Se connecter",
"sign_up_link": "S'inscrire"
},
"BaseTemplate": {
"description": "Code de démarrage pour Next.js avec Tailwind CSS",
"made_with": "Fait avec <author></author>."
},
"Index": {
"meta_title": "Présentation de Next.js Boilerplate",
"meta_description": "Next js Boilerplate est le code de démarrage parfait pour votre projet. Construisez votre application React avec le framework Next.js.",
"sponsors_title": "Partenaires"
},
"Counter": {
"meta_title": "Compteur",
"meta_description": "Un exemple d'opération DB",
"security_powered_by": "Sécurité, détection de bot et rate limiting propulsés par"
},
"CounterForm": {
"presentation": "Le compteur est stocké dans la base de données et incrémenté par la valeur que vous fournissez.",
"label_increment": "Incrémenter de",
"button_increment": "Incrémenter",
"error_increment_range": "La valeur doit être entre 1 et 3"
},
"CurrentCount": {
"count": "Nombre : {count}"
},
"About": {
"meta_title": "A propos",
"meta_description": "A propos description",
"about_paragraph": "Bienvenue sur notre page À propos ! Nous sommes une équipe de passionnés et dévoués à la création de logiciels.",
"translation_powered_by": "Traduction propulsée par"
},
"Portfolio": {
"meta_title": "Portfolio",
"meta_description": "Bienvenue sur la page de mon portfolio !",
"presentation": "Bienvenue sur ma page portfolio ! Vous trouverez ici une collection soigneusement organisée de mon travail et de mes réalisations. À travers ce portfolio, je mets en valeur mon expertise, ma créativité et la valeur que je peux apporter à vos projets.",
"portfolio_name": "Portfolio {name}",
"error_reporting_powered_by": "Rapport d'erreur propulsé par",
"coverage_powered_by": "Couverture de code propulsée par"
},
"PortfolioSlug": {
"meta_title": "Portfolio {slug}",
"meta_description": "Description du Portfolio {slug}",
"header": "Portfolio {slug}",
"content": "Créé un ensemble de matériel promotionnel et d'éléments de marquage pour un événement d'entreprise. Conçu un thème visuellement unifié, englobant un logo, des affiches, des bannières et des actifs numériques. Intégrer l'identité de marque du client tout en l'insufflant à une approche contemporaine et innovante.",
"code_review_powered_by": "Code review propulsé par"
},
"SignIn": {
"meta_title": "Se connecter",
"meta_description": "Connectez-vous à votre compte avec facilité."
},
"SignUp": {
"meta_title": "S'inscrire",
"meta_description": "Créez un compte facilement grâce à notre processus d'inscription intuitif."
},
"Dashboard": {
"meta_title": "Tableau de bord",
"hello_message": "Bonjour {email}!",
"alternative_message": "Besoin de fonctionnalités avancées ? Multi-tenant et équipes, rôles et permissions, Shadcn UI, typage de bout en bout avec oRPC, paiement Stripe, mode clair / sombre. Essayez <url></url>."
},
"UserProfile": {
"meta_title": "Profil de l'utilisateur"
},
"DashboardLayout": {
"dashboard_link": "Tableau de bord",
"user_profile_link": "Gérer votre compte",
"sign_out": "Se déconnecter"
}
}

24
src/models/Schema.ts Normal file
View File

@@ -0,0 +1,24 @@
import { integer, pgTable, serial, timestamp } from 'drizzle-orm/pg-core';
// This file defines the structure of your database tables using the Drizzle ORM.
// To modify the database schema:
// 1. Update this file with your desired changes.
// 2. Generate a new migration by running: `npm run db:generate`
// The generated migration file will reflect your schema changes.
// It automatically run the command `db-server:file`, which apply the migration before Next.js starts in development mode,
// Alternatively, if your database is running, you can run `npm run db:migrate` and there is no need to restart the server.
// Need a database for production? Just claim it by running `npm run neon:claim`.
// Tested and compatible with Next.js Boilerplate
export const counterSchema = pgTable('counter', {
id: serial('id').primaryKey(),
count: integer('count').default(0),
updatedAt: timestamp('updated_at', { mode: 'date' })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
});

78
src/proxy.ts Normal file
View File

@@ -0,0 +1,78 @@
import type { NextFetchEvent, NextRequest } from 'next/server';
import { detectBot } from '@arcjet/next';
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
import createMiddleware from 'next-intl/middleware';
import { NextResponse } from 'next/server';
import arcjet from '@/libs/Arcjet';
import { routing } from './libs/I18nRouting';
const handleI18nRouting = createMiddleware(routing);
const isProtectedRoute = createRouteMatcher([
'/dashboard(.*)',
'/:locale/dashboard(.*)',
]);
const isAuthPage = createRouteMatcher([
'/sign-in(.*)',
'/:locale/sign-in(.*)',
'/sign-up(.*)',
'/:locale/sign-up(.*)',
]);
// Improve security with Arcjet
const aj = arcjet.withRule(
detectBot({
mode: 'LIVE',
// Block all bots except the following
allow: [
// See https://docs.arcjet.com/bot-protection/identifying-bots
'CATEGORY:SEARCH_ENGINE', // Allow search engines
'CATEGORY:PREVIEW', // Allow preview links to show OG images
'CATEGORY:MONITOR', // Allow uptime monitoring services
],
}),
);
export default async function proxy(
request: NextRequest,
event: NextFetchEvent,
) {
// Verify the request with Arcjet
// Use `process.env` instead of Env to reduce bundle size in middleware
if (process.env.ARCJET_KEY) {
const decision = await aj.protect(request);
if (decision.isDenied()) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
// Clerk keyless mode doesn't work with i18n, this is why we need to run the middleware conditionally
if (
isAuthPage(request) || isProtectedRoute(request)
) {
return clerkMiddleware(async (auth, req) => {
if (isProtectedRoute(req)) {
const locale = req.nextUrl.pathname.match(/(\/.*)\/dashboard/)?.at(1) ?? '';
const signInUrl = new URL(`${locale}/sign-in`, req.url);
await auth.protect({
unauthenticatedUrl: signInUrl.toString(),
});
}
return handleI18nRouting(req);
})(request, event);
}
return handleI18nRouting(request);
}
export const config = {
// Match all pathnames except for
// - … if they start with `/_next`, `/_vercel` or `monitoring`
// - … the ones containing a dot (e.g. `favicon.ico`)
matcher: '/((?!_next|_vercel|monitoring|.*\\..*).*)',
};

3
src/styles/global.css Normal file
View File

@@ -0,0 +1,3 @@
@layer theme, base, clerk, components, utilities; /* Ensure Clerk is compatible with Tailwind CSS v4 */
@import 'tailwindcss';

View File

@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { NextIntlClientProvider } from 'next-intl';
import messages from '@/locales/en.json';
import { BaseTemplate } from './BaseTemplate';
const meta = {
title: 'Example/BaseTemplate',
component: BaseTemplate,
parameters: {
layout: 'fullscreen',
},
decorators: [
Story => (
<NextIntlClientProvider locale="en" messages={messages}>
<Story />
</NextIntlClientProvider>
),
],
} satisfies Meta<typeof BaseTemplate>;
export default meta;
type Story = StoryObj<typeof meta>;
export const BaseWithReactComponent: Story = {
args: {
children: <div>Children node</div>,
leftNav: (
<>
<li>Link 1</li>
<li>Link 2</li>
</>
),
},
};
export const BaseWithString: Story = {
args: {
...BaseWithReactComponent.args,
children: 'String',
},
};

View File

@@ -0,0 +1,54 @@
import { NextIntlClientProvider } from 'next-intl';
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-react';
import { page } from 'vitest/browser';
import messages from '@/locales/en.json';
import { BaseTemplate } from './BaseTemplate';
describe('Base template', () => {
describe('Render method', () => {
it('should have 3 menu items', () => {
render(
<NextIntlClientProvider locale="en" messages={messages}>
<BaseTemplate
leftNav={(
<>
<li>link 1</li>
<li>link 2</li>
<li>link 3</li>
</>
)}
>
{null}
</BaseTemplate>
</NextIntlClientProvider>,
);
const menuItemList = page.getByRole('listitem');
expect(menuItemList.elements()).toHaveLength(3);
});
it('should have a link to support nextjs-boilerplate.com', () => {
render(
<NextIntlClientProvider locale="en" messages={messages}>
<BaseTemplate leftNav={<li>1</li>}>{null}</BaseTemplate>
</NextIntlClientProvider>,
);
const copyrightSection = page.getByText(/© Copyright/);
const copyrightLink = copyrightSection.getByRole('link');
/*
* PLEASE READ THIS SECTION
* We'll really appreciate if you could have a link to our website
* The link doesn't need to appear on every pages, one link on one page is enough.
* Thank you for your support it'll mean a lot for us.
*/
expect(copyrightLink).toHaveAttribute(
'href',
'https://nextjs-boilerplate.com',
);
});
});
});

View File

@@ -0,0 +1,61 @@
import { useTranslations } from 'next-intl';
import { AppConfig } from '@/utils/AppConfig';
export const BaseTemplate = (props: {
leftNav: React.ReactNode;
rightNav?: React.ReactNode;
children: React.ReactNode;
}) => {
const t = useTranslations('BaseTemplate');
return (
<div className="w-full px-1 text-gray-700 antialiased">
<div className="mx-auto max-w-screen-md">
<header className="border-b border-gray-300">
<div className="pt-16 pb-8">
<h1 className="text-3xl font-bold text-gray-900">
{AppConfig.name}
</h1>
<h2 className="text-xl">{t('description')}</h2>
</div>
<div className="flex justify-between">
<nav aria-label="Main navigation">
<ul className="flex flex-wrap gap-x-5 text-xl">
{props.leftNav}
</ul>
</nav>
<nav>
<ul className="flex flex-wrap gap-x-5 text-xl">
{props.rightNav}
</ul>
</nav>
</div>
</header>
<main>{props.children}</main>
<footer className="border-t border-gray-300 py-8 text-center text-sm">
{`© Copyright ${new Date().getFullYear()} ${AppConfig.name}. `}
{t.rich('made_with', {
author: () => (
<a
href="https://nextjs-boilerplate.com"
className="text-blue-700 hover:border-b-2 hover:border-blue-700"
>
Next.js Boilerplate
</a>
),
})}
{/*
* PLEASE READ THIS SECTION
* I'm an indie maker with limited resources and funds, I'll really appreciate if you could have a link to my website.
* The link doesn't need to appear on every pages, one link on one page is enough.
* For example, in the `About` page. Thank you for your support, it'll mean a lot to me.
*/}
</footer>
</div>
</div>
);
};

10
src/types/I18n.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { routing } from '@/libs/I18nRouting';
import type messages from '@/locales/en.json';
declare module 'next-intl' {
// eslint-disable-next-line ts/consistent-type-definitions
interface AppConfig {
Locale: (typeof routing.locales)[number];
Messages: typeof messages;
}
}

View File

@@ -0,0 +1,50 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
describe('AI Automation Validation', () => {
const rootDir = path.resolve(__dirname, '..', '..');
const configPath = path.join(rootDir, '.coderabbit.yaml');
const readmePath = path.join(rootDir, 'README.md');
describe('CodeRabbit Configuration', () => {
it('should have .coderabbit.yaml configuration file', () => {
expect(fs.existsSync(configPath)).toBe(true);
});
it('should have valid CodeRabbit YAML configuration', () => {
const configContent = fs.readFileSync(configPath, 'utf-8');
// Validate required fields exist in the YAML content
expect(configContent).toBeDefined();
expect(configContent).toContain('language:');
expect(configContent).toContain('reviews:');
expect(configContent).toContain('CodeRabbit');
});
it('should have reviews auto_review enabled', () => {
const configContent = fs.readFileSync(configPath, 'utf-8');
// Check that auto_review section exists with enabled: true
// Using a pattern that ensures we're checking the auto_review section specifically
expect(configContent).toMatch(/auto_review:\s+enabled:\s+true/);
});
});
describe('README Documentation', () => {
it('should mention AI automation in README', () => {
const readmeContent = fs.readFileSync(readmePath, 'utf-8');
// Check for AI-related mentions
expect(readmeContent.toLowerCase()).toContain('ai');
expect(readmeContent.toLowerCase()).toContain('coderabbit');
});
it('should have AI-powered code reviews feature listed', () => {
const readmeContent = fs.readFileSync(readmePath, 'utf-8');
// Check for specific feature mention
expect(readmeContent).toContain('AI-powered code reviews');
});
});
});

23
src/utils/AppConfig.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { LocalizationResource } from '@clerk/types';
import type { LocalePrefixMode } from 'next-intl/routing';
import { enUS, frFR } from '@clerk/localizations';
const localePrefix: LocalePrefixMode = 'as-needed';
// FIXME: Update this configuration file based on your project information
export const AppConfig = {
name: 'Nextjs Starter',
locales: ['en', 'fr'],
defaultLocale: 'en',
localePrefix,
};
const supportedLocales: Record<string, LocalizationResource> = {
en: enUS,
fr: frFR,
};
export const ClerkLocalizations = {
defaultLocale: enUS,
supportedLocales,
};

18
src/utils/DBConnection.ts Normal file
View File

@@ -0,0 +1,18 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { Env } from '@/libs/Env';
import * as schema from '@/models/Schema';
// Need a database for production? Just claim it by running `npm run neon:claim`.
// Tested and compatible with Next.js Boilerplate
export const createDbConnection = () => {
const pool = new Pool({
connectionString: Env.DATABASE_URL,
max: 1,
});
return drizzle({
client: pool,
schema,
});
};

21
src/utils/Helpers.test.ts Normal file
View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { routing } from '@/libs/I18nRouting';
import { getI18nPath } from './Helpers';
describe('Helpers', () => {
describe('getI18nPath function', () => {
it('should not change the path for default language', () => {
const url = '/random-url';
const locale = routing.defaultLocale;
expect(getI18nPath(url, locale)).toBe(url);
});
it('should prepend the locale to the path for non-default language', () => {
const url = '/random-url';
const locale = 'fr';
expect(getI18nPath(url, locale)).toMatch(/^\/fr/);
});
});
});

32
src/utils/Helpers.ts Normal file
View File

@@ -0,0 +1,32 @@
import { routing } from '@/libs/I18nRouting';
export const getBaseUrl = () => {
if (process.env.NEXT_PUBLIC_APP_URL) {
return process.env.NEXT_PUBLIC_APP_URL;
}
if (
process.env.VERCEL_ENV === 'production'
&& process.env.VERCEL_PROJECT_PRODUCTION_URL
) {
return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`;
}
if (process.env.VERCEL_URL) {
return `https://${process.env.VERCEL_URL}`;
}
return 'http://localhost:3000';
};
export const getI18nPath = (url: string, locale: string) => {
if (locale === routing.defaultLocale) {
return url;
}
return `/${locale}${url}`;
};
export const isServer = () => {
return typeof window === 'undefined';
};

View File

@@ -0,0 +1,5 @@
import * as z from 'zod';
export const CounterValidation = z.object({
increment: z.number().min(1).max(3),
});