diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..540c5c3 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_BASE_URL='https://next-starter.com' diff --git a/bun.lockb b/bun.lockb index 45bca02..9cab6dc 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 0d9c69d..e21c4a8 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "nextjs-toploader": "^1.6.12", "react": "^18", "react-dom": "^18", - "superstruct": "^2.0.2", + "schema-dts": "^1.1.2", + "zod": "^3.23.8", "zustand": "^4.5.4" }, "devDependencies": { @@ -31,7 +32,7 @@ "rimraf": "^6.0.1", "semantic-release": "^24.2.0", "tailwindcss": "^3.4.1", - "turbo": "^2.0.9", + "turbo": "latest", "typescript": "^5.5.4", "typescript-coverage-report": "^1.0.0" }, @@ -40,6 +41,7 @@ "private": true, "scripts": { "alias:load": "source local-aliases.sh", + "analyze": "turbo analyze", "build": "next build", "bundlesize:check": "bunx bundlewatch --config bundlewatch.config.json", "clear": "rimraf .next .turbo node_modules", @@ -61,9 +63,14 @@ "start": "next start", "type:check": "tsc --noEmit", "type:coverage": "typescript-coverage-report", - "update:check": "bun x npm-check-updates" + "update:check": "bunx npm-check-updates", + "update:install": "bunx npm-check-updates --deep -u && bun install" }, "sideEffects": false, "type": "module", - "version": "0.0.1" + "version": "0.0.1", + "workspaces": [ + "apps/*", + "packages/*" + ] } diff --git a/src/app/layout.tsx b/src/app/(route)/layout.tsx similarity index 71% rename from src/app/layout.tsx rename to src/app/(route)/layout.tsx index fcc609b..dae255a 100644 --- a/src/app/layout.tsx +++ b/src/app/(route)/layout.tsx @@ -1,3 +1,4 @@ +import { ROOT_LAYOUT_METADATA } from "@/app/(route)/seo/metadata" import { TopLoaderDynamic } from "@/components/top-loader" import "@/style/globals.css" @@ -20,4 +21,10 @@ function RootLayout(props: RootLayoutProps) { ) } +function generateMetadata() { + return ROOT_LAYOUT_METADATA +} + +export { generateMetadata } + export default RootLayout diff --git a/src/app/page.tsx b/src/app/(route)/page.tsx similarity index 100% rename from src/app/page.tsx rename to src/app/(route)/page.tsx diff --git a/src/app/(route)/seo/json-ld.ts b/src/app/(route)/seo/json-ld.ts new file mode 100644 index 0000000..25a42f3 --- /dev/null +++ b/src/app/(route)/seo/json-ld.ts @@ -0,0 +1,11 @@ +import type { Product, WithContext } from "schema-dts" + +const jsonLd: WithContext = { + "@context": "https://schema.org", + "@type": "Product", + name: "Next.js Sticker", + image: "https://nextjs.org/imgs/sticker.png", + description: "Dynamic at the speed of static.", +} + +export { jsonLd } diff --git a/src/app/(route)/seo/metadata.ts b/src/app/(route)/seo/metadata.ts new file mode 100644 index 0000000..8fa240e --- /dev/null +++ b/src/app/(route)/seo/metadata.ts @@ -0,0 +1,61 @@ +import { env } from "@/env/env.mjs" +import type { Metadata } from "next" + +const ROOT_LAYOUT_METADATA: Metadata = { + alternates: { + canonical: "/", + }, + // description: t("meta.recommend.description"), + icons: { + apple: { + sizes: "180x180", + url: "/favicon/apple-touch-icon.png", + }, + icon: [ + { + sizes: "16x16", + type: "image/png", + url: "/favicon/favicon-16x16.png", + }, + { + sizes: "32x32", + type: "image/png", + url: "/favicon/favicon-32x32.png", + }, + ], + other: { + rel: "mask-icon", + url: "/favicon/safari-pinned-tab.svg", + }, + shortcut: "/favicon/favicon.ico", + }, + manifest: "/site.webmanifest", + metadataBase: new URL(env.NEXT_PUBLIC_BASE_URL), + // openGraph: { + // description: t("meta.recommend.description"), + // images: [ + // { + // height: 635, + // url: `${envClient.NEXT_PUBLIC_FRONTEND_URL}/images/common/OpenGraph.png`, + // width: 1200, + // }, + // ], + // locale: "en", + // siteName: "Recommend", + // title: t("meta.recommend.title"), + // type: "website", + // url: `/${removeTrailingSlash(pathname || "")}`, + // }, + // other: { + // google: "notranslate", + // }, + // title: { + // default: t("meta.recommend.title"), + // template: "%s | Recommend", + // }, + twitter: { + card: "summary_large_image", + }, +} + +export { ROOT_LAYOUT_METADATA } diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 0000000..37bc507 --- /dev/null +++ b/src/app/manifest.ts @@ -0,0 +1,22 @@ +import type { MetadataRoute } from "next" + +function manifest() { + return { + name: "next-starter", + short_name: "next-starter", + description: "next-starter", + start_url: "/", + display: "standalone", + background_color: "#fff", + theme_color: "#fff", + icons: [ + { + src: "/favicon.ico", + sizes: "any", + type: "image/x-icon", + }, + ], + } satisfies MetadataRoute.Manifest +} + +export default manifest diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 0000000..b7904ba --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,14 @@ +import type { MetadataRoute } from "next" + +/* generate a robots.txt static file */ +function robots() { + return { + rules: { + userAgent: "*", + allow: "/", + }, + sitemap: "https://next-starter/sitemap.xml", + } satisfies MetadataRoute.Robots +} + +export default robots diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 0000000..4e5bcc5 --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,15 @@ +import type { MetadataRoute } from "next" + +/* generate a sitemap.xml static file */ +function sitemap() { + return [ + { + url: "https://next-starter.com", + lastModified: new Date(), + changeFrequency: "weekly", + priority: 1, + }, + ] satisfies MetadataRoute.Sitemap +} + +export default sitemap diff --git a/src/apps/web/package.json b/src/apps/web/package.json new file mode 100644 index 0000000..d74c142 --- /dev/null +++ b/src/apps/web/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "next": "latest", + "@repo/ui": "*" + } +} diff --git a/src/apps/web/tsconfig.json b/src/apps/web/tsconfig.json new file mode 100644 index 0000000..c8970b6 --- /dev/null +++ b/src/apps/web/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../../tsconfig.json", + "include": ["src"] +} diff --git a/src/components/head/head.tsx b/src/components/head/head.tsx new file mode 100644 index 0000000..81a96b0 --- /dev/null +++ b/src/components/head/head.tsx @@ -0,0 +1,95 @@ +import type { ReactNode } from "react" + +type HeadProps = { + children: ReactNode + seo: { + title: string + description: string + image: string + keywords: string + ogType: string + ogImage?: string + ogTitle?: string + ogDescription?: string + canonical: string + robots: "index, follow" | "noindex, follow" | "all" + twitterImage?: string + twitterTitle?: string + twitterDescription?: string + twitterCardType?: "summary" | "summary_large_image" | "app" | "player" + twitterSite?: string | null + author?: string + noSiteLinksSearchbox?: boolean + noTranslate?: boolean + applicationName?: string + publisher?: string + generator?: string + themeColor?: string + referrer?: + | "no-referrer" + | "origin" + | "origin-when-cross-origin" + | "strict-origin" + | "strict-origin-when-cross-origin" + | "unsafe-url" + } +} + +const Head = (props: HeadProps) => { + const { children, seo } = props + + const { + title, + description, + image, + keywords, + robots, + canonical, + author, + ogType, + twitterSite, + applicationName, + publisher, + generator, + referrer, + ogImage = image, + ogTitle = title, + ogDescription = description, + twitterImage = image, + twitterTitle = title, + twitterDescription = description, + noTranslate = false, + noSiteLinksSearchbox = false, + twitterCardType = "summary_large_image", + } = seo + + return ( + <> + {title} + + + + + + + + + + + + + + {twitterSite ? : null} + {author ? : null} + {applicationName ? : null} + {publisher ? : null} + {generator ? : null} + {referrer ? : null} + {noSiteLinksSearchbox ? : null} + {noTranslate ? : null} + {children} + + ) +} + +export { Head } diff --git a/src/components/head/index.ts b/src/components/head/index.ts new file mode 100644 index 0000000..1e6d9b2 --- /dev/null +++ b/src/components/head/index.ts @@ -0,0 +1 @@ +export { Head } from "@/components/head/head" diff --git a/src/env/env.mjs b/src/env/env.mjs index bcd26f3..6152200 100644 --- a/src/env/env.mjs +++ b/src/env/env.mjs @@ -1,9 +1,14 @@ import { createEnv } from "@t3-oss/env-nextjs" +import { z } from "zod" const env = createEnv({ server: {}, - client: {}, - runtimeEnv: {}, + client: { + NEXT_PUBLIC_BASE_URL: z.string().url(), + }, + runtimeEnv: { + NEXT_PUBLIC_BASE_URL: process.env["NEXT_PUBLIC_BASE_URL"], + }, }) export { env } diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..8f9f1f7 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,52 @@ +import { type NextRequest, NextResponse } from "next/server" + +function safeURL(url: URL | string): URL | null { + try { + return new URL(url) + } catch { + return null + } +} + +function verifyRequestOrigin(origin: string, allowedDomains: string[]): boolean { + if (!origin || allowedDomains.length === 0) return false + const originHost = safeURL(origin)?.host ?? null + if (!originHost) return false + for (const domain of allowedDomains) { + let host: string | null + if (domain.startsWith("http://") || domain.startsWith("https://")) { + host = safeURL(domain)?.host ?? null + } else { + host = safeURL(`https://${domain}`)?.host ?? null + } + if (originHost === host) return true + } + return false +} + +/* CSRF protection */ +export async function middleware(request: NextRequest) { + if (request.method === "GET") { + return NextResponse.next() + } + + const originHeader = request.headers.get("Origin") + + /* check both `X-Forwarded-Host` and `Host` headers */ + const hostHeader = request.headers.get("X-Forwarded-Host") ?? request.headers.get("Host") + + if (!originHeader || !hostHeader || !verifyRequestOrigin(originHeader, [hostHeader])) { + return new NextResponse(null, { status: 403 }) + } + + return NextResponse.next() +} + +const config = { + /* Skip all non-content paths */ + matcher: [ + "/((?!api|_next|favicon|images|fonts|.well-known|pwa-sw.js|robots|_not-found|sitemap|site.webmanifest).*)", + ], +} + +export { config } diff --git a/src/packages/ui/package.json b/src/packages/ui/package.json new file mode 100644 index 0000000..28fe258 --- /dev/null +++ b/src/packages/ui/package.json @@ -0,0 +1,5 @@ +{ + "exports": { + ".": "./src/index.ts" + } +} diff --git a/src/packages/ui/src/index.ts b/src/packages/ui/src/index.ts new file mode 100644 index 0000000..bf5a031 --- /dev/null +++ b/src/packages/ui/src/index.ts @@ -0,0 +1,3 @@ +const test = () => {} + +export { test } diff --git a/src/packages/ui/tsconfig.json b/src/packages/ui/tsconfig.json new file mode 100644 index 0000000..c8970b6 --- /dev/null +++ b/src/packages/ui/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../../tsconfig.json", + "include": ["src"] +} diff --git a/turbo.json b/turbo.json index 3bfc7a7..95f83db 100644 --- a/turbo.json +++ b/turbo.json @@ -6,7 +6,7 @@ "outputs": [".next/**", "!.next/cache/**"] }, "check-types": { - "dependsOn": ["^check-types"] + "dependsOn": ["^type:check"] }, "dev": { "cache": false,