├── .env.sample ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── snippets.code-snippets ├── LISCENSE ├── README.md ├── STEPS.md ├── app ├── favicon.ico ├── globals.css ├── layout.tsx └── page.tsx ├── components ├── AutomaticScroller.tsx ├── SubmitButton.tsx ├── message-box │ ├── MessageBox.tsx │ ├── MessageDisplay.tsx │ └── MessageInput.tsx └── ui │ ├── Button.tsx │ └── Spinner.tsx ├── data ├── actions │ └── submitMessage.ts └── services │ ├── getCurrentUser.ts │ └── getMessages.ts ├── db.ts ├── next-env.d.ts ├── next.config.js ├── next14-message-box.code-workspace ├── package-lock.json ├── package.json ├── postcss.config.js ├── prisma ├── migrations │ ├── 20240427170059_init │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── seed.ts ├── tailwind.config.ts ├── tsconfig.json ├── utils ├── cn.ts └── slow.ts └── validations └── messageSchema.ts /.env.sample: -------------------------------------------------------------------------------- 1 | # Environment variables declared in this file are automatically made available to Prisma. 2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema 3 | 4 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. 5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 6 | 7 | # # URL for local developement, switch prisma type to sqlite in schema.prisma 8 | # DATABASE_URL="file:./dev.db" 9 | # Database connection string 10 | DATABASE_URL=secret 11 | # Shadow database connection string for development 12 | SHADOW_DATABASE_URL=secret -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | next-env.d.ts -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "eslint-config-prettier", 9 | "plugin:react/recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:import/recommended", 12 | "plugin:jsx-a11y/recommended", 13 | "next", 14 | "next/core-web-vitals", 15 | "prettier" 16 | ], 17 | "parser": "@typescript-eslint/parser", 18 | "parserOptions": { 19 | "ecmaFeatures": { 20 | "jsx": true 21 | }, 22 | "ecmaVersion": 12, 23 | "sourceType": "module" 24 | }, 25 | "plugins": ["autofix", "react-hooks", "sort-keys-fix"], 26 | "rules": { 27 | "sort-keys-fix/sort-keys-fix": "warn" 28 | }, 29 | "overrides": [ 30 | { 31 | "files": ["**/*.ts?(x)"], 32 | "rules": { 33 | "react/react-in-jsx-scope": "off", 34 | "spaced-comment": "warn", 35 | "quotes": ["warn", "single"], 36 | "no-console": "warn", 37 | "no-redeclare": "warn", 38 | "react/display-name": "error", 39 | "react/jsx-key": "warn", 40 | "arrow-body-style": ["warn", "always"], 41 | "react/self-closing-comp": ["error", { "component": true, "html": true }], 42 | "autofix/no-unused-vars": [ 43 | "warn", 44 | { 45 | "argsIgnorePattern": "^_", 46 | "ignoreRestSiblings": true, 47 | "destructuredArrayIgnorePattern": "^_" 48 | } 49 | ], 50 | "@typescript-eslint/consistent-type-imports": [ 51 | "warn", 52 | { 53 | "prefer": "type-imports" 54 | } 55 | ], 56 | "import/order": [ 57 | "warn", 58 | { 59 | "groups": ["builtin", "external", "parent", "sibling", "index", "object", "type"], 60 | "pathGroups": [ 61 | { 62 | "pattern": "@/**/**", 63 | "group": "parent", 64 | "position": "before" 65 | } 66 | ], 67 | "alphabetize": { "order": "asc" } 68 | } 69 | ] 70 | } 71 | } 72 | ] 73 | } 74 | -------------------------------------------------------------------------------- /.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 | # database 12 | dev.db 13 | dev.db-journal 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | 19 | # production 20 | /build 21 | 22 | # misc 23 | .DS_Store 24 | *.pem 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # local env files 32 | .env*.local 33 | .env 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | yarn.lock 4 | pnpm-lock.yaml 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "bracketSpacing": true, 4 | "tabWidth": 2, 5 | "useTabs": false, 6 | "semi": true, 7 | "singleQuote": true, 8 | "trailingComma": "all", 9 | "arrowParens": "avoid", 10 | "endOfLine": "auto", 11 | "plugins": ["prettier-plugin-tailwindcss"] 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/snippets.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "messageState": { 3 | "scope": "typescript", 4 | "prefix": "messageState", 5 | "body": [ 6 | "type State = {", 7 | "\tsuccess: boolean;", 8 | "\terror?: string;", 9 | "\ttimestamp?: Date;", 10 | "\tcontent?: string;", 11 | "}", 12 | "$0" 13 | ], 14 | }, 15 | "disclaimer": { 16 | "body": [ 17 | "/**", 18 | " * Disclaimer: You don’t want to pass userId from the client-side in a real app.", 19 | " * It’s simply an example on how to pass additional params.", 20 | " * You would want a server-side authentication setup i.e getCurrentUser().", 21 | " */" 22 | ], 23 | "scope": "typescript", 24 | "prefix": "disclaimer" 25 | }, 26 | "errorMessage": { 27 | "scope": "typescriptreact", 28 | "prefix": "errorMessage", 29 | "body": [ 30 | "{state.error && {${1:state.error}}}" 31 | ], 32 | }, 33 | "errorBoundary": { 34 | "scope": "typescriptreact", 35 | "prefix": "errorBoundary", 36 | "body": [ 37 | "⚠️Something went wrong

}>", 38 | "\t$1", 39 | "
" 40 | ], 41 | }, 42 | "styledDiv": { 43 | "scope": "typescriptreact", 44 | "prefix": "styledDiv", 45 | "body": [ 46 | "
", 47 | "\t$1", 48 | "
" 49 | ], 50 | } 51 | } -------------------------------------------------------------------------------- /LISCENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Aurora Scharff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies ßof the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js 14 Message Box 2 | 3 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 4 | 5 | It displays a messaging box using Next.js 14 with Server Actions, Tailwind CSS, and Prisma, and is progressively enhanced with React 19 features. 6 | 7 | A deployed, slightly altered version of the optimistic message box can be found [here](https://next15-remix-contacts-rebuild-v2.vercel.app/). 8 | 9 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 10 | 11 | ## Getting Started 12 | 13 | First, force install the dependencies to make the React 19 Beta work: 14 | 15 | ```bash 16 | npm install --force 17 | ``` 18 | 19 | Then, run the development server: 20 | 21 | ```bash 22 | npm run dev 23 | # or 24 | yarn dev 25 | # or 26 | pnpm dev 27 | ``` 28 | 29 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 30 | 31 | ## Prisma Setup 32 | 33 | You need decide between prisma local development with `sqlite` or a real database with for example `sqlserver`. Define it in the `schema.prisma` file. 34 | 35 | Consider adding a `.env` file to the root of the project and using these inside `schema.prisma` with `env("DATABASE_URL")`, refer to `.env.sample`. 36 | 37 | After switching, delete the `prisma/migrations` folder before running the migration command. 38 | 39 | When using sqlserver, you need to migrate the database schema with: 40 | 41 | ```bash 42 | npm run prisma.migrate 43 | ``` 44 | 45 | When using sqllite, initialize with: 46 | 47 | ```bash 48 | npm run prisma.push 49 | ``` 50 | 51 | Seed prisma/seed.ts for initial data: 52 | 53 | ```sh 54 | npm run prisma.seed 55 | ``` 56 | 57 | ## Learn More 58 | 59 | To learn more about Next.js, take a look at the following resources: 60 | 61 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 62 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 63 | 64 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 65 | 66 | ## Deploy on Vercel 67 | 68 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 69 | 70 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 71 | -------------------------------------------------------------------------------- /STEPS.md: -------------------------------------------------------------------------------- 1 | # DEMO STEPS 2 | 3 | ## Introduction 4 | 5 | - Thanks for the introduction 6 | - Aurora, web dev, norway, consultant at Inmeta in oslo, not meta, we had this name in 1996 7 | - I was able to step in for another speaker, and its been hectic getting here, but Im super excited to be here to be speaking here today. Will be demoing a practical example on working with forms and react server components. 8 | - Going to code a simplified version of something i’ve built for my customer project, where im actively using React Server Components, and it's working well for us. 9 | 10 | ## Setup and starting point 11 | 12 | - App router, prisma and local DB, tailwind CSS 13 | - This is now only server components. Show each component. Explain server components 14 | "- "but what I'm doing here can be done with client apps with a little more work to create endpoints" 15 | - Lets enhance this message box with rsc and react 19! Goal: make it interactive while minimizing js on the client and reducing forntend complexity. 16 | 17 | ## Basic form with server action 18 | 19 | (MessageInput + submitMessage) 20 | 21 | - Attach action prop using React's extension of the form element, auto transition 22 | - "this could be an action on the client, but since we're using server components, we can pass a server action instead" 23 | - Code submitMessage server action, data access layer 24 | - Submit to db, add hidden userId field. Mention .bind as an way to pass additional props. NB! Should be a part of the cookie or course and authentication but this is a demo, use auth or getcurrentuser. 25 | - RevalidatePath purge cache 26 | 27 | Notes: Lets start with the basic funcitonality. Make the form work and submit after reload with form action and hidden userId. When called with server action, behaves differently than native form, server action will have a post endpoint generated and be **exposed to the client** and can called without js. Then revalidatePath. “Just by doing that…”. Using native forms rather than buttons with onClicks, “had we used the onSubmit we would need React to have hydrated this page to be able to submit the form”. 28 | 29 | ## Add scroll handler 30 | 31 | (MessageBox) 32 | 33 | - Add donut pattern listener and explain, try it out 34 | - Show the devtools that the server components arent there but the scroller is 35 | 36 | Notes: Contains a children prop. Could be any prop. Can pass anything here, for example server components. Only the js for the scroll handler is loaded. 37 | 38 | ## Validate data 39 | 40 | (submitMessage) 41 | 42 | - Validate data with zod by moving object, throw error, use result in db insert, remove "as string" 43 | - Remove required on input 44 | - Add error boundary and show it triggering 45 | - Add back required on input 46 | 47 | Notes: Don't trust the input from the client. Handle errors however, for example error boundary. Show zod. 48 | 49 | ## Return validation 50 | 51 | (submitMessage) 52 | 53 | - Return instead of throw error and timestamp (create timestamp) 54 | - Add max limit messages sent 5. 55 | - Return success 56 | - Get this to the user: useActionState, add initial state and add span "errorMessage" 57 | - Show the error in the form. 58 | - Pass _prevState 59 | 60 | Notes: Could check for any requirements for your data. Create a component state when a form action is invoked. Can be called without js and return state without js. UseActionState returns a wrapped action, when called useActionState will return the last result of the action. Could use this to return the field errors. 61 | 62 | ## Toast message count 63 | 64 | (MessageInput) 65 | 66 | - useEffect to toast on error, depend on timestamp and error 67 | - Change span tag to noscript 68 | 69 | Notes: Noscript is a fallback. 70 | 71 | ## Return content for rollback on reset 72 | 73 | (MessageInput) 74 | 75 | - Explain form reset 76 | - Return result.data.content in the payload. 77 | 78 | Notes: 79 | React 19 the automatically resets uncontrolled inputs automatically after the action finishes. Follows the mpa form submission behavior. Probably used to using a library that would control forms, like react-hook-form. Not needed. Maintain the entered value when there is error. Maybe this could be changed to be valid. Let's return the content and set it as the defaultValue so it's not lost. 80 | 81 | ## Slow server action 82 | 83 | (submitMessage, MessageInput) 84 | 85 | - Add slow() to server action 86 | - Use third argument to show feedback 87 | - Increase max messages to 8 and demo again 88 | 89 | Notes: Realistic with a real db. Show feedback. We don't need to make an api endpoint and set error states etc like we used to in the next.js app router, which was a hassle. 90 | 91 | ## DEMO 92 | 93 | - By the way, this works without js! 94 | - Add some, we dont get automatic scrolling or button feedback or toasts, because all that requires js on the client. 95 | - Demo without js until it fails. 96 | - Turn js back on and show the feedback. 97 | 98 | ## Explanation 99 | 100 | - What we've been doing is progressively enhancing this, meaning ensuring the basic functionality works at the lowest level of resources, no javascript, then adding things on top to enhance the user experience for users with those resources available. 101 | - Lets say your user is on a slow device or slow connection and still waiting for js to finish downloading, parsing, or executing. This will work before its loaded, and will make the hydration for the JS that we do want load faster, because we reduced the amount of js on the client weaving the server component and the client together. Now depending on the user’s situation, they will get the better experience, and always have a form that works. 102 | 103 | ## Replace with submitButton 104 | 105 | (SubmitButton + MessageBox) 106 | 107 | - Reuse this logic 108 | - Extract button to submitbutton with useformstatus and spinner 109 | - Say you can generalize this better, extend button element 110 | - Add new button to the rsc-header and code the server action, inline server action: "use server", slow, delete, revalidate 111 | 112 | Completely composable and standardized SubmitButton for our application. Pretty amazing. Power of RSC. 113 | 114 | ## Optimistic update 115 | 116 | - Stash current code 117 | - Switch branch 118 | - Show code for messagebox and messages 119 | - Show messageInput and explain how it works, action fallback 120 | - Send multiple messages slowly, then many until it fails 121 | 122 | Notes: Can even enhance this further with optimistic updates. This still works without js. Adding an onSubmit for client-side js only functionality, use a state with defaultvalue maintain the progressive enhancement. 123 | 124 | Of course, depending on your app you can decide how to implement forms and whether you still want your react-hook form and whatnot, but by using the the more primitive features of the web together with React 19 and React Server Components, we can make our forms very robust and while maintaining a great user experience. And there is alot more to come from these. They will be primitives for libraries simpliying things for developers, focus on building apps. 125 | 126 | That's it for this demo, the code is pinned on my GitHub and the optimistic update is on a branch, and follow me on Twitter if you are interested in more rsc content. Thanks for listening and thanks React Universe Conf! 127 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/next-message-box/d8c4e699ce8e556e28f87b52cfeccee6f901188c/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './globals.css'; 2 | import { Inter } from 'next/font/google'; 3 | import type { Metadata } from 'next'; 4 | 5 | const inter = Inter({ subsets: ['latin'] }); 6 | 7 | export const metadata: Metadata = { 8 | description: 'Next.js 14 message box example using server actions and useActionState', 9 | title: 'Next.js 14 Message Box', 10 | }; 11 | 12 | export default function RootLayout({ children }: { children: React.ReactNode }) { 13 | return ( 14 | 15 | {children} 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Toaster } from 'react-hot-toast'; 3 | import MessageBox from '@/components/message-box/MessageBox'; 4 | 5 | export default function Home() { 6 | return ( 7 | <> 8 | 9 |
10 | 11 |
12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /components/AutomaticScroller.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useEffect, useRef } from 'react'; 4 | 5 | type Props = { 6 | children: React.ReactNode; 7 | className?: string; 8 | }; 9 | 10 | export default function AutomaticScroller({ children, className }: Props) { 11 | const ref = useRef(null); 12 | 13 | useEffect(() => { 14 | const mutationObserver = new MutationObserver(() => { 15 | if (ref.current) { 16 | ref.current.scroll({ behavior: 'smooth', top: ref.current.scrollHeight }); 17 | } 18 | }); 19 | 20 | if (ref.current) { 21 | mutationObserver.observe(ref.current, { 22 | childList: true, 23 | }); 24 | } 25 | 26 | return () => { 27 | mutationObserver.disconnect(); 28 | }; 29 | }, []); 30 | 31 | return ( 32 |
33 | {children} 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /components/SubmitButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import { useFormStatus } from 'react-dom'; 5 | import Button from './ui/Button'; 6 | import Spinner from './ui/Spinner'; 7 | 8 | export default function SubmitButton({ children, disabled, ...otherProps }: React.HTMLProps) { 9 | const { pending } = useFormStatus(); 10 | 11 | return ( 12 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /components/message-box/MessageBox.tsx: -------------------------------------------------------------------------------- 1 | import { revalidatePath } from 'next/cache'; 2 | import React from 'react'; 3 | import { ErrorBoundary } from 'react-error-boundary'; 4 | 5 | import { getCurrentUser } from '@/data/services/getCurrentUser'; 6 | import { getMessages } from '@/data/services/getMessages'; 7 | import { prisma } from '@/db'; 8 | import { slow } from '@/utils/slow'; 9 | import AutomaticScroller from '../AutomaticScroller'; 10 | import SubmitButton from '../SubmitButton'; 11 | import MessageDisplay from './MessageDisplay'; 12 | import MessageInput from './MessageInput'; 13 | 14 | export default async function MessageBox() { 15 | const messages = await getMessages(); 16 | const user = await getCurrentUser(); 17 | 18 | // Should be extracted into data/actions/resetMessages.ts 19 | async function resetMessages() { 20 | 'use server'; 21 | 22 | await slow(); 23 | await prisma.message.deleteMany(); 24 | revalidatePath('/'); 25 | } 26 | 27 | return ( 28 |
29 |
30 |

Messages

31 |
32 | Reset 33 |
34 |
35 |
36 | 37 | {messages.length === 0 && No messages} 38 | {messages.map(message => { 39 | return ; 40 | })} 41 | 42 | ⚠️Something went wrong

}> 43 | 44 |
45 |
46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /components/message-box/MessageDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cn } from '@/utils/cn'; 3 | import type { Message } from '@prisma/client'; 4 | 5 | type Props = { 6 | message: Message; 7 | userId: string; 8 | }; 9 | 10 | export default function MessageDisplay({ message, userId }: Props) { 11 | const isWrittenByUser = userId === message.createdById; 12 | 13 | return ( 14 |
20 | 21 | {isWrittenByUser ? 'You' : 'Them'} 22 | {' - '} 23 | 24 | {message.createdAt.toLocaleString('en-US', { 25 | day: 'numeric', 26 | hour: 'numeric', 27 | minute: 'numeric', 28 | month: 'numeric', 29 | year: 'numeric', 30 | })} 31 | 32 | 33 | {message.content} 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /components/message-box/MessageInput.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useActionState, useEffect } from 'react'; 4 | import toast from 'react-hot-toast'; 5 | import { submitMessage } from '@/data/actions/submitMessage'; 6 | import SubmitButton from '../SubmitButton'; 7 | 8 | type Props = { 9 | userId: string; 10 | }; 11 | 12 | export default function MessageInput({ userId }: Props) { 13 | const [state, submitMessageAction] = useActionState(submitMessage, { 14 | success: false, 15 | }); 16 | 17 | useEffect(() => { 18 | if (state.error) { 19 | toast.error(state.error); 20 | } 21 | }, [state.error, state.timestamp]); 22 | 23 | return ( 24 | <> 25 |
26 | 35 | 36 | Send 37 |
38 | {state.error && } 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /components/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type Props = { 4 | children: React.ReactNode; 5 | }; 6 | 7 | export default function Button({ children, ...otherProps }: Props & React.ButtonHTMLAttributes) { 8 | return ( 9 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /components/ui/Spinner.tsx: -------------------------------------------------------------------------------- 1 | export default function Spinner() { 2 | return ( 3 |
4 | 12 | 19 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /data/actions/submitMessage.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { revalidatePath } from 'next/cache'; 4 | import { prisma } from '@/db'; 5 | import { slow } from '@/utils/slow'; 6 | import { messageSchema } from '@/validations/messageSchema'; 7 | import { getMessages } from '../services/getMessages'; 8 | 9 | type State = { 10 | success: boolean; 11 | error?: string; 12 | timestamp?: Date; 13 | content?: string; 14 | }; 15 | 16 | /** 17 | * Disclaimer: You wouldn’t want to pass the user id from the client side like this in a real app. 18 | * It’s simply an example on how to pass additional params. 19 | * You would want to do everything server-side with an authentication setup and something like getCurrentUser() 20 | */ 21 | export async function submitMessage(_prevState: State, formData: FormData): Promise { 22 | await slow(); 23 | 24 | const timestamp = new Date(); 25 | 26 | const result = messageSchema.safeParse({ 27 | content: formData.get('content'), 28 | createdById: formData.get('createdById'), 29 | }); 30 | 31 | if (!result.success) { 32 | return { 33 | error: 'Invalid message!', 34 | success: false, 35 | timestamp, 36 | }; 37 | } 38 | 39 | const messages = await getMessages(result.data.createdById); 40 | 41 | if (messages.length > 10) { 42 | return { 43 | content: result.data.content, 44 | error: 'Your message limit has been reached.', 45 | success: false, 46 | timestamp, 47 | }; 48 | } 49 | 50 | await prisma.message.create({ 51 | data: result.data, 52 | }); 53 | 54 | revalidatePath('/'); 55 | 56 | return { 57 | success: true, 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /data/services/getCurrentUser.ts: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | 3 | import { cache } from 'react'; 4 | import { prisma } from '@/db'; 5 | 6 | /** 7 | * It is likely that your getCurrentUser will be called mutliple times in the same render. 8 | * Therefore, it has been deduplicated with React cache. 9 | */ 10 | export const getCurrentUser = cache(async () => { 11 | const users = await prisma.user.findMany(); 12 | return users[0]; 13 | }); 14 | -------------------------------------------------------------------------------- /data/services/getMessages.ts: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | 3 | import { prisma } from '@/db'; 4 | 5 | export async function getMessages(userId?: string) { 6 | return prisma.message.findMany({ 7 | orderBy: { createdAt: 'asc' }, 8 | where: { 9 | createdById: userId, 10 | }, 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const globalForPrisma = global as unknown as { 4 | prisma: PrismaClient | undefined; 5 | }; 6 | 7 | export const prisma = 8 | globalForPrisma.prisma ?? 9 | new PrismaClient({ 10 | log: ['query'], 11 | }); 12 | 13 | if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; 14 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | module.exports = nextConfig; 5 | -------------------------------------------------------------------------------- /next14-message-box.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "[html]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "[css]": { 12 | "editor.defaultFormatter": "esbenp.prettier-vscode" 13 | }, 14 | "[javascript]": { 15 | "editor.defaultFormatter": "esbenp.prettier-vscode" 16 | }, 17 | "[typescript]": { 18 | "editor.defaultFormatter": "esbenp.prettier-vscode" 19 | }, 20 | "[typescriptreact]": { 21 | "editor.defaultFormatter": "esbenp.prettier-vscode" 22 | }, 23 | "[json]": { 24 | "editor.defaultFormatter": "esbenp.prettier-vscode" 25 | }, 26 | "[jsonc]": { 27 | "editor.defaultFormatter": "esbenp.prettier-vscode" 28 | }, 29 | "editor.codeActionsOnSave": { 30 | "source.fixAll.eslint": "explicit" 31 | }, 32 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next14-message-box", 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 | "prisma.studio": "npx prisma studio", 11 | "prisma.seed": "npx prisma db seed", 12 | "prisma.migrate": "npx prisma migrate dev", 13 | "prisma.push": "npx prisma db push" 14 | }, 15 | "dependencies": { 16 | "@prisma/client": "^5.12.1", 17 | "@types/node": "20.12.7", 18 | "@types/react": "18.3.1", 19 | "@types/react-dom": "18.3.0", 20 | "autoprefixer": "10.4.19", 21 | "clsx": "^2.1.1", 22 | "eslint": "8.56.0", 23 | "eslint-config-next": "14.2.3", 24 | "next": "14.3.0-canary.57", 25 | "postcss": "^8.4.38", 26 | "react": "19.0.0-beta-4508873393-20240430", 27 | "react-dom": "19.0.0-beta-4508873393-20240430", 28 | "react-error-boundary": "^4.0.13", 29 | "react-hot-toast": "^2.4.1", 30 | "server-only": "^0.0.1", 31 | "tailwind-merge": "^2.3.0", 32 | "tailwindcss": "3.4.3", 33 | "typescript": "5.4.5", 34 | "zod": "^3.23.4" 35 | }, 36 | "devDependencies": { 37 | "@tailwindcss/typography": "^0.5.13", 38 | "@types/eslint": "^8.56.10", 39 | "@typescript-eslint/eslint-plugin": "^6.18.0", 40 | "eslint-config-prettier": "^9.1.0", 41 | "eslint-plugin-autofix": "^1.1.0", 42 | "eslint-plugin-sort-keys-fix": "^1.1.2", 43 | "prettier": "^3.2.5", 44 | "prettier-plugin-tailwindcss": "^0.5.14", 45 | "prisma": "^5.12.1", 46 | "ts-node": "^10.9.2", 47 | "tsconfig-paths": "^4.2.0" 48 | }, 49 | "prisma": { 50 | "seed": "ts-node prisma/seed.ts" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /prisma/migrations/20240427170059_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Message" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "createdById" TEXT NOT NULL, 6 | "content" TEXT NOT NULL, 7 | CONSTRAINT "Message_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 8 | ); 9 | 10 | -- CreateTable 11 | CREATE TABLE "User" ( 12 | "id" TEXT NOT NULL PRIMARY KEY, 13 | "name" TEXT NOT NULL 14 | ); 15 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "sqlite" 10 | url = "file:./dev.db" 11 | } 12 | 13 | model Message { 14 | id String @id @default(uuid()) 15 | createdAt DateTime @default(now()) 16 | createdBy User @relation(fields: [createdById], references: [id]) 17 | createdById String 18 | content String 19 | } 20 | 21 | model User { 22 | id String @id @default(uuid()) 23 | name String 24 | Message Message[] 25 | } 26 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import type { User } from '@prisma/client'; 3 | 4 | const prisma = new PrismaClient(); 5 | 6 | const MESSAGES = [ 7 | { 8 | content: 'Hello, your application has been approved!', 9 | createdAt: new Date('2024-09-03T00:00:00Z'), 10 | createdById: '3ea4ae6c-adda-40eb-b254-9cfe0c8e8113', 11 | id: '0cd89022-64e8-4a76-aec6-43433478e32f', 12 | }, 13 | { 14 | content: 'Great, what are the next steps?', 15 | createdAt: new Date('2024-09-04T00:01:00Z'), 16 | createdById: '2bccacd4-64de-4f1d-97ed-9722cdf99cd9', 17 | id: '0cd89022-64e8-4a76-aec6-43433478e32f', 18 | }, 19 | ]; 20 | 21 | const USERS: User[] = [ 22 | { 23 | id: '2bccacd4-64de-4f1d-97ed-9722cdf99cd9', 24 | name: 'John Doe', 25 | }, 26 | { 27 | id: '3ea4ae6c-adda-40eb-b254-9cfe0c8e8113', 28 | name: 'Jane Doe', 29 | }, 30 | ]; 31 | 32 | async function seedMessages() { 33 | await Promise.all( 34 | USERS.map(n => { 35 | return prisma.user.create({ data: { id: n.id, name: n.name } }); 36 | }), 37 | ) 38 | .then(() => { 39 | return console.info('[SEED] Succussfully create user records'); 40 | }) 41 | .catch(e => { 42 | return console.error('[SEED] Failed to create user records', e); 43 | }); 44 | await Promise.all( 45 | MESSAGES.map(n => { 46 | return prisma.message.create({ 47 | data: { 48 | content: n.content, 49 | createdAt: n.createdAt, 50 | createdById: n.createdById, 51 | }, 52 | }); 53 | }), 54 | ) 55 | .then(() => { 56 | return console.info('[SEED] Succussfully create message records'); 57 | }) 58 | .catch(e => { 59 | return console.error('[SEED] Failed to create message records', e); 60 | }); 61 | } 62 | 63 | seedMessages(); 64 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | const config: Config = { 4 | content: [ 5 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | plugins: [], 10 | theme: { 11 | extend: {}, 12 | }, 13 | }; 14 | export default config; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "ts-node": { 27 | "compilerOptions": { 28 | "module": "CommonJS" 29 | } 30 | }, 31 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 32 | "exclude": ["node_modules"] 33 | } 34 | -------------------------------------------------------------------------------- /utils/cn.ts: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | import type { ClassValue } from 'clsx'; 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)); 7 | } 8 | -------------------------------------------------------------------------------- /utils/slow.ts: -------------------------------------------------------------------------------- 1 | export async function slow() { 2 | await new Promise(resolve => { 3 | return setTimeout(resolve, 1000); 4 | }); 5 | } 6 | -------------------------------------------------------------------------------- /validations/messageSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const messageSchema = z.object({ 4 | content: z.string().min(1, { 5 | message: 'Content must be at least 1 characters long', 6 | }), 7 | createdById: z.string().uuid({ 8 | message: 'Invalid user ID', 9 | }), 10 | }); 11 | --------------------------------------------------------------------------------