├── .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 |