├── .prettierignore ├── src ├── app │ ├── favicon.ico │ ├── page.tsx │ ├── layout.tsx │ ├── demo │ │ ├── server-component.tsx │ │ └── client-component.tsx │ └── globals.css ├── utils │ ├── cn.ts │ └── get-cookie-value.ts ├── features │ └── user-preferences │ │ ├── action.ts │ │ ├── cookies.ts │ │ ├── server.tsx │ │ └── client.tsx └── components │ ├── card.tsx │ └── select.tsx ├── .dockerignore ├── postcss.config.js ├── .prettierrc.cjs ├── next.config.js ├── fly.toml ├── public ├── vercel.svg └── next.svg ├── .gitignore ├── tsconfig.json ├── .eslintrc.js ├── package.json ├── Dockerfile ├── tailwind.config.js └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | /build 2 | /public/build 3 | 4 | /.*/ 5 | *lock.json -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rphlmr/nextjs-client-hints/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm-debug.log 5 | README.md 6 | .next 7 | .git -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Options} */ 2 | module.exports = { 3 | tabWidth: 4, 4 | useTabs: true, 5 | }; 6 | -------------------------------------------------------------------------------- /src/utils/cn.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | serverActions: true, 5 | }, 6 | output: "standalone", 7 | poweredByHeader: false, 8 | }; 9 | 10 | module.exports = nextConfig; 11 | -------------------------------------------------------------------------------- /src/utils/get-cookie-value.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from "next/headers"; 2 | 3 | export function getCookieValue(name: string) { 4 | const cookieStore = cookies(); 5 | 6 | const value = cookieStore.get(name)?.value; 7 | 8 | return value ? decodeURIComponent(value) : null; 9 | } 10 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for next-hints on 2023-09-10T20:30:48+02:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = "next-hints" 7 | primary_region = "cdg" 8 | 9 | [build] 10 | 11 | [http_service] 12 | internal_port = 3000 13 | force_https = true 14 | auto_stop_machines = true 15 | auto_start_machines = true 16 | min_machines_running = 0 17 | processes = ["app"] 18 | -------------------------------------------------------------------------------- /src/features/user-preferences/action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { cookies } from "next/headers"; 4 | import { userPreferences } from "./cookies"; 5 | 6 | export async function setTheme(theme: string) { 7 | const { cookieName } = userPreferences.theme; 8 | 9 | if (theme !== "light" && theme !== "dark" && theme !== "system") { 10 | throw new Error("Invalid theme"); 11 | } 12 | 13 | if (theme === "system") { 14 | cookies().delete(cookieName); 15 | } else { 16 | cookies().set(cookieName, theme); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | # lockfiles 38 | package-lock.json 39 | yarn.lock 40 | pnpm-lock.yaml 41 | bun.lockb 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"], 4 | parser: "@typescript-eslint/parser", 5 | plugins: ["@typescript-eslint"], 6 | root: true, 7 | rules: { 8 | "@typescript-eslint/no-explicit-any": "warn", 9 | "@typescript-eslint/consistent-type-imports": [ 10 | "error", 11 | { 12 | prefer: "type-imports", 13 | fixStyle: "inline-type-imports", 14 | }, 15 | ], 16 | "@typescript-eslint/no-unused-vars": [ 17 | "warn", 18 | { 19 | vars: "all", 20 | args: "all", 21 | argsIgnorePattern: "^_", 22 | destructuredArrayIgnorePattern: "^_", 23 | ignoreRestSiblings: false, 24 | }, 25 | ], 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { GitHubLogoIcon } from "@radix-ui/react-icons"; 3 | import { ClientComponent } from "./demo/client-component"; 4 | import { ServerComponent } from "./demo/server-component"; 5 | 6 | export default function Home() { 7 | const serverDate = new Date().toISOString(); 8 | 9 | return ( 10 | <> 11 |
12 |

Try to change your location (devtools > sensors)

13 |

Try to change your locale (ex: Locale Switcher addon)

14 |
15 | 16 | 17 | 18 | 22 | 23 | @rphlmr 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-hint", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "format": "prettier --write ." 11 | }, 12 | "dependencies": { 13 | "@radix-ui/react-icons": "^1.3.0", 14 | "@radix-ui/react-select": "^1.2.2", 15 | "@types/node": "20.5.9", 16 | "@types/react": "18.2.21", 17 | "@types/react-dom": "18.2.7", 18 | "autoprefixer": "10.4.15", 19 | "class-variance-authority": "^0.7.0", 20 | "clsx": "^2.0.0", 21 | "eslint-config-next": "13.4.19", 22 | "next": "13.4.19", 23 | "postcss": "8.4.29", 24 | "react": "18.2.0", 25 | "react-dom": "18.2.0", 26 | "tailwind-merge": "^1.14.0", 27 | "tailwindcss": "3.3.3", 28 | "tailwindcss-animate": "^1.0.7" 29 | }, 30 | "devDependencies": { 31 | "@typescript-eslint/eslint-plugin": "^6.6.0", 32 | "@typescript-eslint/parser": "^6.6.0", 33 | "eslint": "^8.48.0", 34 | "prettier": "^3.0.3", 35 | "typescript": "5.2.2" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/features/user-preferences/cookies.ts: -------------------------------------------------------------------------------- 1 | export type Theme = "light" | "dark"; 2 | 3 | export const clientHints = { 4 | prefersColorScheme: { 5 | cookieName: "ch-prefers-color-scheme", 6 | getValueCode: `window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'`, 7 | fallback: "light" as Theme, 8 | transform(value: string) { 9 | return (value === "dark" ? "dark" : "light") satisfies Theme; 10 | }, 11 | }, 12 | timeZone: { 13 | cookieName: "ch-time-zone", 14 | getValueCode: `Intl.DateTimeFormat().resolvedOptions().timeZone`, 15 | fallback: "UTC", 16 | }, 17 | locale: { 18 | cookieName: "ch-locale", 19 | getValueCode: `window.navigator.language`, 20 | fallback: "en", 21 | }, 22 | // add other hints here 23 | }; 24 | 25 | const preferences = { 26 | theme: { 27 | cookieName: "up-theme", 28 | maxAge: 60 * 60 * 24 * 365, // 1 year 29 | fallback: "" as Theme | "", 30 | }, 31 | // add other user preferences here 32 | }; 33 | 34 | export const userPreferences = { 35 | ...clientHints, 36 | ...preferences, 37 | }; 38 | 39 | export type UserPreferencesName = keyof typeof userPreferences; 40 | -------------------------------------------------------------------------------- /src/features/user-preferences/server.tsx: -------------------------------------------------------------------------------- 1 | import { getCookieValue } from "../../utils/get-cookie-value"; 2 | import { userPreferences, type UserPreferencesName } from "./cookies"; 3 | 4 | /** 5 | * Get the user preferences from cookies. 6 | * 7 | * **Server only** 8 | * 9 | * @returns an object with the user preferences 10 | */ 11 | export function getUserPreferences() { 12 | return Object.entries(userPreferences).reduce( 13 | (acc, [name, hint]) => { 14 | const hintName = name as UserPreferencesName; 15 | const cookieName = userPreferences[hintName].cookieName; 16 | 17 | if (!cookieName) { 18 | throw new Error(`Unknown user preferences cookie: ${hintName}`); 19 | } 20 | 21 | if ("transform" in hint) { 22 | acc[hintName] = hint.transform( 23 | getCookieValue(cookieName) ?? hint.fallback, 24 | ); 25 | } else { 26 | // @ts-expect-error - this is fine (PRs welcome though) 27 | acc[hintName] = getCookieValue(cookieName) ?? hint.fallback; 28 | } 29 | 30 | return acc; 31 | }, 32 | {} as { 33 | [name in UserPreferencesName]: (typeof userPreferences)[name] extends { 34 | transform: (value: unknown) => infer ReturnValue; 35 | } 36 | ? ReturnValue 37 | : (typeof userPreferences)[name]["fallback"]; 38 | }, 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getUserPreferences } from "@/features/user-preferences/server"; 2 | import "./globals.css"; 3 | import type { Metadata } from "next"; 4 | import { Inter } from "next/font/google"; 5 | import { 6 | ClientHintsCheck, 7 | ThemeSelector, 8 | UserPreferencesProvider, 9 | } from "@/features/user-preferences/client"; 10 | import { cn } from "@/utils/cn"; 11 | 12 | const inter = Inter({ subsets: ["latin"] }); 13 | 14 | export const metadata: Metadata = { 15 | title: "Create Next App", 16 | description: "Generated by create next app", 17 | }; 18 | 19 | export default function RootLayout({ 20 | children, 21 | }: { 22 | children: React.ReactNode; 23 | }) { 24 | const userPreferences = getUserPreferences(); 25 | const theme = userPreferences.theme || userPreferences.prefersColorScheme; 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 40 | 43 | 44 |
{children}
45 | 46 | 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/app/demo/server-component.tsx: -------------------------------------------------------------------------------- 1 | import { getUserPreferences } from "@/features/user-preferences/server"; 2 | import { 3 | Card, 4 | CardContent, 5 | CardDescription, 6 | CardHeader, 7 | CardItem, 8 | CardTitle, 9 | } from "@/components/card"; 10 | 11 | export function ServerComponent({ serverDate }: { serverDate: string }) { 12 | const { locale, prefersColorScheme, theme, timeZone } = 13 | getUserPreferences(); 14 | 15 | return ( 16 | 17 | 18 | 👋 I am a 🌍 server component 19 | 20 | I use the getUserPreferences function. 21 | 22 | 23 | 24 | 25 | {prefersColorScheme} 26 | 27 | {theme || "default"} 28 | {locale} 29 | {timeZone} 30 | 31 | 41 | 42 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/app/demo/client-component.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useUserPreferences } from "@/features/user-preferences/client"; 4 | import { 5 | Card, 6 | CardContent, 7 | CardDescription, 8 | CardHeader, 9 | CardItem, 10 | CardTitle, 11 | } from "@/components/card"; 12 | 13 | export function ClientComponent({ serverDate }: { serverDate: string }) { 14 | const { locale, prefersColorScheme, theme, timeZone } = 15 | useUserPreferences(); 16 | 17 | return ( 18 | 19 | 20 | 👋 I am a 🖥️ client component 21 | 22 | I use the useUserPreferences hook. 23 | 24 | 25 | 26 | 27 | {prefersColorScheme} 28 | 29 | {theme || "default"} 30 | {locale} 31 | {timeZone} 32 | 33 | 43 | 44 | 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 47.4% 11.2%; 9 | 10 | --muted: 210 40% 96.1%; 11 | --muted-foreground: 215.4 16.3% 46.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 47.4% 11.2%; 15 | 16 | --border: 214.3 31.8% 91.4%; 17 | --input: 214.3 31.8% 91.4%; 18 | 19 | --card: 0 0% 100%; 20 | --card-foreground: 222.2 47.4% 11.2%; 21 | 22 | --primary: 222.2 47.4% 11.2%; 23 | --primary-foreground: 210 40% 98%; 24 | 25 | --secondary: 210 40% 96.1%; 26 | --secondary-foreground: 222.2 47.4% 11.2%; 27 | 28 | --accent: 210 40% 96.1%; 29 | --accent-foreground: 222.2 47.4% 11.2%; 30 | 31 | --destructive: 0 100% 50%; 32 | --destructive-foreground: 210 40% 98%; 33 | 34 | --ring: 215 20.2% 65.1%; 35 | 36 | --radius: 0.5rem; 37 | } 38 | 39 | .dark { 40 | --background: 224 71% 4%; 41 | --foreground: 213 31% 91%; 42 | 43 | --muted: 223 47% 11%; 44 | --muted-foreground: 215.4 16.3% 56.9%; 45 | 46 | --accent: 216 34% 17%; 47 | --accent-foreground: 210 40% 98%; 48 | 49 | --popover: 224 71% 4%; 50 | --popover-foreground: 215 20.2% 65.1%; 51 | 52 | --border: 216 34% 17%; 53 | --input: 216 34% 17%; 54 | 55 | --card: 224 71% 4%; 56 | --card-foreground: 213 31% 91%; 57 | 58 | --primary: 210 40% 98%; 59 | --primary-foreground: 222.2 47.4% 1.2%; 60 | 61 | --secondary: 222.2 47.4% 11.2%; 62 | --secondary-foreground: 210 40% 98%; 63 | 64 | --destructive: 0 63% 31%; 65 | --destructive-foreground: 210 40% 98%; 66 | 67 | --ring: 216 34% 17%; 68 | 69 | --radius: 0.5rem; 70 | } 71 | } 72 | 73 | @layer base { 74 | * { 75 | @apply border-border; 76 | } 77 | body { 78 | @apply bg-background text-foreground; 79 | font-feature-settings: 80 | "rlig" 1, 81 | "calt" 1; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine AS base 2 | 3 | # Install dependencies only when needed 4 | FROM base AS deps 5 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 6 | RUN apk add --no-cache libc6-compat 7 | RUN yarn global add pnpm 8 | WORKDIR /app 9 | 10 | # Install dependencies based on the preferred package manager 11 | COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* bun.lockb* ./ 12 | RUN pnpm i; 13 | 14 | 15 | # Rebuild the source code only when needed 16 | FROM base AS builder 17 | WORKDIR /app 18 | COPY --from=deps /app/node_modules ./node_modules 19 | COPY . . 20 | 21 | # Next.js collects completely anonymous telemetry data about general usage. 22 | # Learn more here: https://nextjs.org/telemetry 23 | # Uncomment the following line in case you want to disable telemetry during the build. 24 | # ENV NEXT_TELEMETRY_DISABLED 1 25 | 26 | RUN yarn run build 27 | 28 | # If using npm comment out above and use below instead 29 | # RUN npm run build 30 | 31 | # Production image, copy all the files and run next 32 | FROM base AS runner 33 | WORKDIR /app 34 | 35 | ENV NODE_ENV production 36 | # Uncomment the following line in case you want to disable telemetry during runtime. 37 | # ENV NEXT_TELEMETRY_DISABLED 1 38 | 39 | RUN addgroup --system --gid 1001 nodejs 40 | RUN adduser --system --uid 1001 nextjs 41 | 42 | COPY --from=builder /app/public ./public 43 | 44 | # Set the correct permission for prerender cache 45 | RUN mkdir .next 46 | RUN chown nextjs:nodejs .next 47 | 48 | # Automatically leverage output traces to reduce image size 49 | # https://nextjs.org/docs/advanced-features/output-file-tracing 50 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 51 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 52 | 53 | USER nextjs 54 | 55 | EXPOSE 3000 56 | 57 | ENV PORT 3000 58 | # set hostname to localhost 59 | ENV HOSTNAME "0.0.0.0" 60 | 61 | CMD ["node", "server.js"] -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | import { fontFamily } from "tailwindcss/defaultTheme"; 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export const darkMode = ["class"]; 5 | export const content = ["src/**/*.{ts,tsx}"]; 6 | export const theme = { 7 | container: { 8 | center: true, 9 | padding: "2rem", 10 | screens: { 11 | "2xl": "1400px", 12 | }, 13 | }, 14 | extend: { 15 | colors: { 16 | border: "hsl(var(--border))", 17 | input: "hsl(var(--input))", 18 | ring: "hsl(var(--ring))", 19 | background: "hsl(var(--background))", 20 | foreground: "hsl(var(--foreground))", 21 | primary: { 22 | DEFAULT: "hsl(var(--primary))", 23 | foreground: "hsl(var(--primary-foreground))", 24 | }, 25 | secondary: { 26 | DEFAULT: "hsl(var(--secondary))", 27 | foreground: "hsl(var(--secondary-foreground))", 28 | }, 29 | destructive: { 30 | DEFAULT: "hsl(var(--destructive))", 31 | foreground: "hsl(var(--destructive-foreground))", 32 | }, 33 | muted: { 34 | DEFAULT: "hsl(var(--muted))", 35 | foreground: "hsl(var(--muted-foreground))", 36 | }, 37 | accent: { 38 | DEFAULT: "hsl(var(--accent))", 39 | foreground: "hsl(var(--accent-foreground))", 40 | }, 41 | popover: { 42 | DEFAULT: "hsl(var(--popover))", 43 | foreground: "hsl(var(--popover-foreground))", 44 | }, 45 | card: { 46 | DEFAULT: "hsl(var(--card))", 47 | foreground: "hsl(var(--card-foreground))", 48 | }, 49 | }, 50 | borderRadius: { 51 | lg: `var(--radius)`, 52 | md: `calc(var(--radius) - 2px)`, 53 | sm: "calc(var(--radius) - 4px)", 54 | }, 55 | fontFamily: { 56 | sans: ["var(--font-sans)", ...fontFamily.sans], 57 | }, 58 | keyframes: { 59 | "accordion-down": { 60 | from: { height: 0 }, 61 | to: { height: "var(--radix-accordion-content-height)" }, 62 | }, 63 | "accordion-up": { 64 | from: { height: "var(--radix-accordion-content-height)" }, 65 | to: { height: 0 }, 66 | }, 67 | }, 68 | animation: { 69 | "accordion-down": "accordion-down 0.2s ease-out", 70 | "accordion-up": "accordion-up 0.2s ease-out", 71 | }, 72 | }, 73 | }; 74 | export const plugins = [require("tailwindcss-animate")]; 75 | -------------------------------------------------------------------------------- /src/components/card.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | 5 | import { cn } from "@/utils/cn"; 6 | 7 | const Card = React.forwardRef< 8 | HTMLDivElement, 9 | React.HTMLAttributes 10 | >(({ className, ...props }, ref) => ( 11 |
19 | )); 20 | Card.displayName = "Card"; 21 | 22 | const CardHeader = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes 25 | >(({ className, ...props }, ref) => ( 26 |
31 | )); 32 | CardHeader.displayName = "CardHeader"; 33 | 34 | const CardTitle = React.forwardRef< 35 | HTMLParagraphElement, 36 | React.HTMLAttributes 37 | >(({ className, ...props }, ref) => ( 38 |

43 | )); 44 | CardTitle.displayName = "CardTitle"; 45 | 46 | const CardDescription = React.forwardRef< 47 | HTMLParagraphElement, 48 | React.HTMLAttributes 49 | >(({ className, ...props }, ref) => ( 50 |

55 | )); 56 | CardDescription.displayName = "CardDescription"; 57 | 58 | const CardContent = React.forwardRef< 59 | HTMLDivElement, 60 | React.HTMLAttributes 61 | >(({ className, ...props }, ref) => ( 62 |

63 | )); 64 | CardContent.displayName = "CardContent"; 65 | 66 | const CardFooter = React.forwardRef< 67 | HTMLDivElement, 68 | React.HTMLAttributes 69 | >(({ className, ...props }, ref) => ( 70 |
75 | )); 76 | CardFooter.displayName = "CardFooter"; 77 | 78 | const CardItem = ({ 79 | title, 80 | children, 81 | }: { 82 | title: string; 83 | children: React.ReactNode; 84 | }) => { 85 | return ( 86 |
87 |
88 |

{title}:

89 |
{children}
90 |
91 |
92 | ); 93 | }; 94 | 95 | export { 96 | Card, 97 | CardHeader, 98 | CardFooter, 99 | CardTitle, 100 | CardDescription, 101 | CardContent, 102 | CardItem, 103 | }; 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://github.com/rphlmr/nextjs-client-hints/assets/20722140/d873666a-ff41-4bca-bbbe-999ec505c4fd 5 | 6 | 7 | 8 | This is a [Next.js](https://nextjs.org/) demo handling client hints and user preferences without any hydration error. 9 | 10 | It is largely based on the Remix Run stack [The Epic Stack](https://github.com/epicweb-dev/epic-stack) from Kent C. Dodds and 11 | contributors. 12 | 13 | ## Getting Started 14 | 15 | First, run the development server: 16 | 17 | ```bash 18 | npm run dev 19 | # or 20 | yarn dev 21 | # or 22 | pnpm dev 23 | ``` 24 | 25 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 26 | 27 | ## What is solved here 28 | 29 | You have no request headers for the user timezone or it color scheme preference. 30 | If you want to use one of this, you can only access that client side and you will have hydration issues: server side rendered content will be different from the client side rendered one. 31 | 32 | ## How to use it 33 | Just copy/paste the [src/utils](src/utils) folder in your project and use the `ClientHintsProvider` and `UserPreferencesProvider` in your root layout. 34 | 35 | ```tsx 36 | export default function RootLayout({ 37 | children, 38 | }: { 39 | children: React.ReactNode; 40 | }) { 41 | const clientHints = getClientHints(); 42 | const userPrefs = getUserPrefs(); 43 | const theme = userPrefs.theme || clientHints.prefersColorScheme; 44 | 45 | return ( 46 | 47 | 48 | 49 | 50 | 57 | 58 | 59 | 62 | 63 |
{children}
64 |
65 |
66 | 67 | 68 | ); 69 | } 70 | ``` 71 | 72 | ## Learn More 73 | 74 | All the non-magic happens in the [src/utils/client-hints](src/utils/client-hints) & [src/utils/user-preferences](src/utils/user-preferences/) folders. 75 | 76 | In [src/utils/client-hints/components.tsx](src/utils/client-hints/components.tsx), `ClientHintsCheck` is a component that is rendered in the `` of our document before anything else. That component renders a small and fast inline script which checks the user's cookies for the expected client hints. If they are not present or if they're outdated, it sets a cookie and triggers a reload of the page. 77 | 78 | Then, we use regular `cookies()` function in root layout to read the cookie and set the client hints in a `ClientHintsProvider`. 79 | We do the same for user preferences (like its selected theme). 80 | 81 | See [Client Hints decision from The Epic Stack](https://github.com/epicweb-dev/epic-stack/blob/e20e5e1b18a62d793a4ead0a542dca65cb23fb9a/docs/client-hints.md) 82 | 83 | ## Learn more about client hints 84 | The [Client Hints](https://developer.mozilla.org/en-US/docs/Web/HTTP/Client_hints) are a set of HTTP request header fields that a server can proactively request from a client to get information about the device, network, user, and user-agent-specific preferences. The server can determine which resources to send, based on the information that the client chooses to provide. 85 | -------------------------------------------------------------------------------- /src/components/select.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; 5 | import * as SelectPrimitive from "@radix-ui/react-select"; 6 | 7 | import { cn } from "@/utils/cn"; 8 | 9 | const Select = SelectPrimitive.Root; 10 | 11 | const SelectGroup = SelectPrimitive.Group; 12 | 13 | const SelectValue = SelectPrimitive.Value; 14 | 15 | const SelectTrigger = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, children, ...props }, ref) => ( 19 | 27 | {children} 28 | 29 | 30 | 31 | 32 | )); 33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; 34 | 35 | const SelectContent = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, children, position = "popper", ...props }, ref) => ( 39 | 40 | 51 | 58 | {children} 59 | 60 | 61 | 62 | )); 63 | SelectContent.displayName = SelectPrimitive.Content.displayName; 64 | 65 | const SelectLabel = React.forwardRef< 66 | React.ElementRef, 67 | React.ComponentPropsWithoutRef 68 | >(({ className, ...props }, ref) => ( 69 | 74 | )); 75 | SelectLabel.displayName = SelectPrimitive.Label.displayName; 76 | 77 | const SelectItem = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef 80 | >(({ className, children, ...props }, ref) => ( 81 | 89 | 90 | 91 | 92 | 93 | 94 | {children} 95 | 96 | )); 97 | SelectItem.displayName = SelectPrimitive.Item.displayName; 98 | 99 | const SelectSeparator = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )); 109 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName; 110 | 111 | export { 112 | Select, 113 | SelectGroup, 114 | SelectValue, 115 | SelectTrigger, 116 | SelectContent, 117 | SelectLabel, 118 | SelectItem, 119 | SelectSeparator, 120 | }; 121 | -------------------------------------------------------------------------------- /src/features/user-preferences/client.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useTransition } from "react"; 4 | import { DesktopIcon, MoonIcon, SunIcon } from "@radix-ui/react-icons"; 5 | import { useRouter } from "next/navigation"; 6 | import { type getUserPreferences } from "./server"; 7 | import { 8 | Select, 9 | SelectContent, 10 | SelectItem, 11 | SelectTrigger, 12 | SelectValue, 13 | } from "@/components/select"; 14 | import { cn } from "@/utils/cn"; 15 | import { setTheme } from "./action"; 16 | import { clientHints } from "./cookies"; 17 | 18 | /* -------------------------------------------------------------------------- */ 19 | /* Context; */ 20 | /* -------------------------------------------------------------------------- */ 21 | 22 | type UserPreferencesContext = ReturnType; 23 | 24 | type UserPreferencesProviderProps = { 25 | userPreferences: UserPreferencesContext; 26 | children: React.ReactNode; 27 | }; 28 | 29 | const Context = React.createContext(null); 30 | 31 | export const UserPreferencesProvider = ({ 32 | userPreferences, 33 | children, 34 | }: UserPreferencesProviderProps) => { 35 | const value = React.useMemo(() => userPreferences, [userPreferences]); 36 | 37 | return {children}; 38 | }; 39 | 40 | export const useUserPreferences = () => { 41 | const context = React.useContext(Context); 42 | 43 | if (!context) { 44 | throw new Error( 45 | `useUserPreferences must be used within UserPreferencesProvider.`, 46 | ); 47 | } 48 | return context; 49 | }; 50 | 51 | /* -------------------------------------------------------------------------- */ 52 | /* Script; */ 53 | /* -------------------------------------------------------------------------- */ 54 | 55 | /** 56 | * @returns inline script element that checks for client hints and sets cookies 57 | * if they are not set then reloads the page if any cookie was set to an 58 | * inaccurate value. 59 | * 60 | * @credit https://github.com/epicweb-dev/epic-stack 61 | */ 62 | export function ClientHintsCheck({ nonce }: { nonce?: string }) { 63 | const { refresh } = useRouter(); 64 | React.useEffect(() => { 65 | const themeQuery = window.matchMedia("(prefers-color-scheme: dark)"); 66 | function handleThemeChange() { 67 | document.cookie = `${clientHints.prefersColorScheme.cookieName}=${ 68 | themeQuery.matches ? "dark" : "light" 69 | }`; 70 | refresh(); 71 | } 72 | themeQuery.addEventListener("change", handleThemeChange); 73 | return () => { 74 | themeQuery.removeEventListener("change", handleThemeChange); 75 | }; 76 | }, [refresh]); 77 | 78 | return ( 79 |